From df6ce2d0000a3b68914a9ae9e50a65dbf35bd875 Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Sun, 13 Sep 2026 03:17:07 +0530 Subject: [PATCH 01/36] Add OAuth 2 client library for Python --- .github/workflows/ci-aiohttp.yml | 44 ++ .github/workflows/ci-httpx.yml | 44 ++ .github/workflows/ci-openapi.yml | 42 ++ .github/workflows/ci-python.yml | 46 ++ .github/workflows/ci-urllib3.yml | 44 ++ .github/workflows/quality.yml | 47 ++ .gitignore | 21 + LICENSE | 13 + README.md | 310 ++++++++++++ pyproject.toml | 65 +++ scripts/check_wheel.py | 57 +++ scripts/generate_test_clients.py | 70 +++ scripts/run_tests.py | 17 + src/mastercard_oauth2_client/__init__.py | 60 +++ .../_internal/__init__.py | 1 + .../_internal/_clock.py | 10 + .../_internal/_encoding.py | 7 + .../_internal/_scopes.py | 7 + .../_internal/http.py | 31 ++ .../_internal/jose.py | 254 ++++++++++ .../_internal/orchestration.py | 109 +++++ src/mastercard_oauth2_client/config.py | 103 ++++ src/mastercard_oauth2_client/dpop.py | 149 ++++++ src/mastercard_oauth2_client/exceptions.py | 6 + .../handlers/__init__.py | 4 + .../handlers/async_.py | 178 +++++++ src/mastercard_oauth2_client/handlers/sync.py | 175 +++++++ .../integrations/__init__.py | 1 + .../integrations/aiohttp.py | 84 ++++ .../integrations/httpx.py | 126 +++++ .../integrations/openapi/__init__.py | 5 + .../integrations/openapi/asyncio.py | 42 ++ .../integrations/openapi/httpx.py | 63 +++ .../integrations/openapi/urllib3.py | 168 +++++++ src/mastercard_oauth2_client/keys.py | 122 +++++ src/mastercard_oauth2_client/models.py | 62 +++ src/mastercard_oauth2_client/protocols.py | 116 +++++ src/mastercard_oauth2_client/py.typed | 0 src/mastercard_oauth2_client/store.py | 63 +++ src/mastercard_oauth2_client/token.py | 160 +++++++ tests/integration/conftest.py | 27 ++ tests/integration/support/__init__.py | 1 + tests/integration/support/assertions.py | 137 ++++++ .../support/authorization_server.py | 203 ++++++++ tests/integration/support/environment.py | 91 ++++ tests/integration/support/lifecycle.py | 64 +++ .../support/lifecycle_clients/__init__.py | 1 + .../support/lifecycle_clients/aiohttp.py | 32 ++ .../support/lifecycle_clients/httpx.py | 57 +++ .../lifecycle_clients/openapi_asyncio.py | 48 ++ .../lifecycle_clients/openapi_httpx.py | 48 ++ .../lifecycle_clients/openapi_urllib3.py | 38 ++ tests/integration/support/models.py | 71 +++ tests/integration/support/resource_server.py | 250 ++++++++++ tests/integration/support/stores.py | 21 + tests/integration/support/tls.py | 52 ++ tests/integration/test_aiohttp.py | 412 ++++++++++++++++ tests/integration/test_httpx.py | 246 ++++++++++ tests/integration/test_httpx_async.py | 299 ++++++++++++ tests/integration/test_openapi.py | 310 ++++++++++++ tests/integration/test_openapi_asyncio.py | 340 +++++++++++++ tests/integration/test_openapi_httpx.py | 248 ++++++++++ tests/resources/openapi/fake-api.yaml | 69 +++ tests/unit/configuration.py | 17 + tests/unit/test_aiohttp_integration.py | 238 ++++++++++ tests/unit/test_async_handler.py | 417 ++++++++++++++++ tests/unit/test_config.py | 255 ++++++++++ tests/unit/test_handler.py | 376 +++++++++++++++ tests/unit/test_httpx_async_integration.py | 91 ++++ tests/unit/test_httpx_integration.py | 46 ++ tests/unit/test_integration_support.py | 24 + tests/unit/test_jose_dpop.py | 449 ++++++++++++++++++ tests/unit/test_keys.py | 287 +++++++++++ tests/unit/test_openapi_adapter.py | 256 ++++++++++ tests/unit/test_openapi_asyncio_adapter.py | 103 ++++ tests/unit/test_openapi_httpx_adapter.py | 165 +++++++ tests/unit/test_orchestration.py | 12 + tests/unit/test_protocols.py | 312 ++++++++++++ tests/unit/test_token.py | 201 ++++++++ 79 files changed, 9240 insertions(+) create mode 100644 .github/workflows/ci-aiohttp.yml create mode 100644 .github/workflows/ci-httpx.yml create mode 100644 .github/workflows/ci-openapi.yml create mode 100644 .github/workflows/ci-python.yml create mode 100644 .github/workflows/ci-urllib3.yml create mode 100644 .github/workflows/quality.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 scripts/check_wheel.py create mode 100644 scripts/generate_test_clients.py create mode 100644 scripts/run_tests.py create mode 100644 src/mastercard_oauth2_client/__init__.py create mode 100644 src/mastercard_oauth2_client/_internal/__init__.py create mode 100644 src/mastercard_oauth2_client/_internal/_clock.py create mode 100644 src/mastercard_oauth2_client/_internal/_encoding.py create mode 100644 src/mastercard_oauth2_client/_internal/_scopes.py create mode 100644 src/mastercard_oauth2_client/_internal/http.py create mode 100644 src/mastercard_oauth2_client/_internal/jose.py create mode 100644 src/mastercard_oauth2_client/_internal/orchestration.py create mode 100644 src/mastercard_oauth2_client/config.py create mode 100644 src/mastercard_oauth2_client/dpop.py create mode 100644 src/mastercard_oauth2_client/exceptions.py create mode 100644 src/mastercard_oauth2_client/handlers/__init__.py create mode 100644 src/mastercard_oauth2_client/handlers/async_.py create mode 100644 src/mastercard_oauth2_client/handlers/sync.py create mode 100644 src/mastercard_oauth2_client/integrations/__init__.py create mode 100644 src/mastercard_oauth2_client/integrations/aiohttp.py create mode 100644 src/mastercard_oauth2_client/integrations/httpx.py create mode 100644 src/mastercard_oauth2_client/integrations/openapi/__init__.py create mode 100644 src/mastercard_oauth2_client/integrations/openapi/asyncio.py create mode 100644 src/mastercard_oauth2_client/integrations/openapi/httpx.py create mode 100644 src/mastercard_oauth2_client/integrations/openapi/urllib3.py create mode 100644 src/mastercard_oauth2_client/keys.py create mode 100644 src/mastercard_oauth2_client/models.py create mode 100644 src/mastercard_oauth2_client/protocols.py create mode 100644 src/mastercard_oauth2_client/py.typed create mode 100644 src/mastercard_oauth2_client/store.py create mode 100644 src/mastercard_oauth2_client/token.py create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/support/__init__.py create mode 100644 tests/integration/support/assertions.py create mode 100644 tests/integration/support/authorization_server.py create mode 100644 tests/integration/support/environment.py create mode 100644 tests/integration/support/lifecycle.py create mode 100644 tests/integration/support/lifecycle_clients/__init__.py create mode 100644 tests/integration/support/lifecycle_clients/aiohttp.py create mode 100644 tests/integration/support/lifecycle_clients/httpx.py create mode 100644 tests/integration/support/lifecycle_clients/openapi_asyncio.py create mode 100644 tests/integration/support/lifecycle_clients/openapi_httpx.py create mode 100644 tests/integration/support/lifecycle_clients/openapi_urllib3.py create mode 100644 tests/integration/support/models.py create mode 100644 tests/integration/support/resource_server.py create mode 100644 tests/integration/support/stores.py create mode 100644 tests/integration/support/tls.py create mode 100644 tests/integration/test_aiohttp.py create mode 100644 tests/integration/test_httpx.py create mode 100644 tests/integration/test_httpx_async.py create mode 100644 tests/integration/test_openapi.py create mode 100644 tests/integration/test_openapi_asyncio.py create mode 100644 tests/integration/test_openapi_httpx.py create mode 100644 tests/resources/openapi/fake-api.yaml create mode 100644 tests/unit/configuration.py create mode 100644 tests/unit/test_aiohttp_integration.py create mode 100644 tests/unit/test_async_handler.py create mode 100644 tests/unit/test_config.py create mode 100644 tests/unit/test_handler.py create mode 100644 tests/unit/test_httpx_async_integration.py create mode 100644 tests/unit/test_httpx_integration.py create mode 100644 tests/unit/test_integration_support.py create mode 100644 tests/unit/test_jose_dpop.py create mode 100644 tests/unit/test_keys.py create mode 100644 tests/unit/test_openapi_adapter.py create mode 100644 tests/unit/test_openapi_asyncio_adapter.py create mode 100644 tests/unit/test_openapi_httpx_adapter.py create mode 100644 tests/unit/test_orchestration.py create mode 100644 tests/unit/test_protocols.py create mode 100644 tests/unit/test_token.py diff --git a/.github/workflows/ci-aiohttp.yml b/.github/workflows/ci-aiohttp.yml new file mode 100644 index 0000000..754c94c --- /dev/null +++ b/.github/workflows/ci-aiohttp.yml @@ -0,0 +1,44 @@ +name: aiohttp + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + tests: + name: aiohttp ${{ matrix.label }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - label: minimum + aiohttp: 'aiohttp==3.13.0' + - label: latest + aiohttp: aiohttp + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev,generator]" "${{ matrix.aiohttp }}" + python -m pip check + + - name: Run aiohttp tests + run: python scripts/run_tests.py -q -m aiohttp diff --git a/.github/workflows/ci-httpx.yml b/.github/workflows/ci-httpx.yml new file mode 100644 index 0000000..9b0fc79 --- /dev/null +++ b/.github/workflows/ci-httpx.yml @@ -0,0 +1,44 @@ +name: HTTPX + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + tests: + name: HTTPX ${{ matrix.label }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - label: minimum + httpx: 'httpx==0.28.0' + - label: latest + httpx: httpx + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev,generator]" "${{ matrix.httpx }}" + python -m pip check + + - name: Run HTTPX tests + run: python scripts/run_tests.py -q -m httpx diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml new file mode 100644 index 0000000..030ccd4 --- /dev/null +++ b/.github/workflows/ci-openapi.yml @@ -0,0 +1,42 @@ +name: OpenAPI Generator + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + generated-clients: + name: OpenAPI Generator ${{ matrix['generator-version'] }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + generator-version: + - '7.23.0' + - '7.24.0' + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.14' + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" "openapi-generator-cli[jdk4py]==${{ matrix['generator-version'] }}" + python -m pip check + + - name: Run generated-client tests + run: python scripts/run_tests.py -q -m generated diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml new file mode 100644 index 0000000..ef3da9e --- /dev/null +++ b/.github/workflows/ci-python.yml @@ -0,0 +1,46 @@ +name: Python + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + tests: + name: Python ${{ matrix['python-version'] }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - python-version: '3.12' + cryptography: 'cryptography==45.0.1' + - python-version: '3.13' + cryptography: cryptography + - python-version: '3.14' + cryptography: cryptography + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python ${{ matrix['python-version'] }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix['python-version'] }} + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" "${{ matrix.cryptography }}" + python -m pip check + + - name: Run tests + run: python -m pytest -q tests/unit tests/integration --ignore-glob='**/test_openapi*.py' diff --git a/.github/workflows/ci-urllib3.yml b/.github/workflows/ci-urllib3.yml new file mode 100644 index 0000000..df98eca --- /dev/null +++ b/.github/workflows/ci-urllib3.yml @@ -0,0 +1,44 @@ +name: urllib3 + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + tests: + name: urllib3 ${{ matrix.label }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - label: minimum + urllib3: 'urllib3==2.1.0' + - label: latest + urllib3: urllib3 + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev,generator]" "${{ matrix.urllib3 }}" + python -m pip check + + - name: Run generated urllib3 tests + run: python scripts/run_tests.py -q -m urllib3 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..1cefb02 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,47 @@ +name: Quality + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev,generator]" + python -m pip check + + - name: Lint + run: python -m ruff check . + + - name: Type check + run: python -m mypy + + - name: Build distributions + run: python -m build + + - name: Check distributions + run: python -m twine check dist/* + + - name: Check wheel + run: python scripts/check_wheel.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25cf513 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +__pycache__/ +*.py[cod] +.coverage +.env +.env.* +!.env.example +.venv/ +.idea/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.cache/ +build/ +dist/ +*.egg-info/ +docs/ +.github/agents/ +.github/ai-framework/ +.github/skills/ +.github/copilot-instructions.md +tests/generated/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8674164 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2025 - 2026 Mastercard + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c3135e7 --- /dev/null +++ b/README.md @@ -0,0 +1,310 @@ +# OAuth2 Client Python + + + + Mastercard Developers + + +## Overview + +Authenticate requests to Mastercard APIs with OAuth 2.0, FAPI 2.0 and DPoP. +The library obtains and reuses access tokens, adds the required authentication +headers and works with supported native and OpenAPI-generated HTTP clients. + +For more information, see +[Using OAuth 2.0 to Access Mastercard APIs](https://mstr.cd/43CuHBY). + +## Requirements + +### License + +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) + +This project is licensed under the Apache License 2.0. + +### Python + +[![Python 3.12+](https://img.shields.io/badge/Python-3.12%2B-3776AB.svg?logo=python&logoColor=white)](.github/workflows/ci-python.yml) + +Python 3.12 or newer is required. + +### Dependencies + +The core package requires only `cryptography`. Optional integrations are +available through extras shown with each supported client below. + +## Usage + +### Installation + +Install the core package: + +```bash +python -m pip install mastercard-oauth2-client +``` + +For HTTPX: + +```bash +python -m pip install "mastercard-oauth2-client[httpx]" +``` + +For aiohttp: + +```bash +python -m pip install "mastercard-oauth2-client[aiohttp]" +``` + +### Configuration + +`OAuth2Config` contains the client credentials, token endpoint, issuer, scopes +and DPoP key configuration. Load your client signing key from PEM, DER or +PKCS#12 and your DPoP key pair from a private JWK: + +```python +from mastercard_oauth2_client import ( + OAuth2Config, + StaticDPoPKeyProvider, + StaticScopeResolver, + load_jwk_key_pair, + load_private_key, +) + +client_key = load_private_key("path/to/client-private-key.pem") +dpop_key_pair = load_jwk_key_pair("path/to/dpop-private-key.json") + +oauth2_config = OAuth2Config( + client_id="your-client-id", + token_endpoint="https://sandbox.api.mastercard.com/oauth/token", + issuer="https://sandbox.api.mastercard.com", + client_key=client_key, + key_id="your-signing-key-id", + scope_resolver=StaticScopeResolver({"your-api:scope"}), + dpop_key_provider=StaticDPoPKeyProvider(dpop_key_pair), +) +``` + +Replace every example value with the credentials and scopes provisioned for +your Mastercard API project. For custom scope resolution, DPoP key management +and token storage, see [Extension Points](#extension-points). + +### Low-Level API + +[`OAuth2Handler`](src/mastercard_oauth2_client/handlers/sync.py) and +[`AsyncOAuth2Handler`](src/mastercard_oauth2_client/handlers/async_.py) run the +OAuth2 and DPoP flow. To support another HTTP client, provide the matching +[`SyncHttpAdapter` or `AsyncHttpAdapter`](src/mastercard_oauth2_client/protocols.py) +and pass it to the handler. For higher-level use, choose a ready-made +integration below. + +### Direct HTTP Client Integration + +For a higher-level experience, use an HTTP client integration to authenticate +requests automatically. Choose the client that best fits your application. +Each integration provides the same OAuth2 and DPoP behavior. + +| HTTP client | Mode | Install | +|---|---|---| +| HTTPX | Sync and async | `mastercard-oauth2-client[httpx]` | +| aiohttp | Async | `mastercard-oauth2-client[aiohttp]` | + +#### HTTPX + +Install `mastercard-oauth2-client[httpx]`. + +`OAuth2Transport` supports `httpx.Client`: + +```python +import httpx + +from mastercard_oauth2_client.integrations.httpx import OAuth2Transport + +with httpx.Client(transport=OAuth2Transport(oauth2_config)) as client: + response = client.get("https://api.mastercard.com/resources") + response.raise_for_status() +``` + +For asynchronous applications, use `AsyncOAuth2Transport` with +`httpx.AsyncClient`: + +```python +import httpx + +from mastercard_oauth2_client.integrations.httpx import AsyncOAuth2Transport + +async with httpx.AsyncClient(transport=AsyncOAuth2Transport(oauth2_config)) as client: + response = await client.get("https://api.mastercard.com/resources") + response.raise_for_status() +``` + +Pass a configured HTTPX transport to the wrapper for custom network settings. + +#### aiohttp + +Install `mastercard-oauth2-client[aiohttp]`, then add `OAuth2Middleware` when +creating the session: + +```python +import aiohttp + +from mastercard_oauth2_client.integrations.aiohttp import OAuth2Middleware + +async with aiohttp.ClientSession( + middlewares=(OAuth2Middleware(oauth2_config),), +) as client: + response = await client.get("https://api.mastercard.com/resources") + response.raise_for_status() + result = await response.json() +``` + +Use normal `ClientSession` options for network settings. + +### OpenAPI Generated Clients + +The library integrates with Python clients produced by OpenAPI Generator. +Attach OAuth2 before the generated client sends its first request. + +| Generated library | Mode | Install | +|---|---|---| +| Default (`urllib3`) | Sync | `mastercard-oauth2-client` | +| `httpx` | Async | `mastercard-oauth2-client[httpx]` | +| `asyncio` | Async | `mastercard-oauth2-client[aiohttp]` | + +#### Python Generator (urllib3) + +The Python generator uses urllib3 when no `--library` option is supplied. + +```python +from generated_client import ApiClient, Configuration, ResourcesApi +from mastercard_oauth2_client import add_oauth2_layer + +api_client = ApiClient(Configuration(host="https://api.mastercard.com")) +add_oauth2_layer(api_client, oauth2_config) + +resources_api = ResourcesApi(api_client) +resources = resources_api.get_resources() +``` + +This adapter is specific to OpenAPI-generated clients. It is not a direct +urllib3 integration. + +#### Python Generator (`httpx`) + +Generate with `--library httpx` and install the `httpx` extra. + +```python +from generated_client import ApiClient, Configuration, ResourcesApi +from mastercard_oauth2_client.integrations.openapi.httpx import add_oauth2_layer + +api_client = ApiClient(Configuration(host="https://api.mastercard.com")) +add_oauth2_layer(api_client, oauth2_config) + +try: + resources_api = ResourcesApi(api_client) + resources = await resources_api.get_resources() +finally: + await api_client.close() +``` + +#### Python Generator (`asyncio`) + +Generate with `--library asyncio` and install the `aiohttp` extra. + +```python +from generated_client import ApiClient, Configuration, ResourcesApi +from mastercard_oauth2_client.integrations.openapi.asyncio import add_oauth2_layer + +api_client = ApiClient(Configuration(host="https://api.mastercard.com")) +add_oauth2_layer(api_client, oauth2_config) + +try: + resources_api = ResourcesApi(api_client) + resources = await resources_api.get_resources() +finally: + await api_client.close() +``` + +## Extension Points + +This library is small by design and can be extended for different application +needs. + +- [`OAuth2Config`](src/mastercard_oauth2_client/config.py) connects credentials, + endpoints and extension points. +- Use `StaticScopeResolver` for fixed scopes, or provide a custom + [`ScopeResolver`](src/mastercard_oauth2_client/protocols.py) to choose scopes + for each request. +- Use `StaticDPoPKeyProvider` for one key pair, or provide a custom + [`DPoPKeyProvider`](src/mastercard_oauth2_client/protocols.py) to supply or + rotate keys. +- Use the default `InMemoryTokenStore`, or provide a custom + [`TokenStore`](src/mastercard_oauth2_client/protocols.py) to control token + storage. +- [Key loading](src/mastercard_oauth2_client/keys.py): loads PEM, DER, PKCS#12 + and private JWK key material. + +## Key Support + +The library supports RSA and EC keys: + +- Load client private keys from PEM, DER or PKCS#12 files. +- Load DPoP key pairs from private JWKs. +- Use RSA keys of at least 2048 bits for PS256 signing. +- Use P-256 EC keys for ES256 signing. + +## Troubleshooting + +### Configuration + +The token endpoint must be an absolute HTTPS URL. Configuration errors raise +`OAuth2ConfigError` with the invalid setting. + +### Logging + +Use Python's standard logging configuration to enable messages from +`mastercard_oauth2_client`. Sensitive tokens, assertions, proofs, nonces, keys +and response bodies are not logged. + +## Test Strategy + +- [`tests/unit`](tests/unit) contains fast tests for protocol rules, caching, + handlers and adapters. +- [`tests/integration`](tests/integration) tests direct and generated clients + against local HTTPS authorization and resource servers. +- [`fake-api.yaml`](tests/resources/openapi/fake-api.yaml) defines the API used + to generate test clients. + +Pytest markers group each integration's unit and integration tests for focused +CI runs. Tests added to an existing marked module inherit its marker. + +## Development + +Install the development tools and pinned default OpenAPI Generator, then run +the quality gates: + +```bash +python -m pip install -e ".[dev,generator]" +python scripts/run_tests.py +python -m ruff check . +python -m mypy +python -m build +``` + +`scripts/run_tests.py` regenerates all OpenAPI test clients before running +pytest. To regenerate without running tests: + +```bash +python scripts/generate_test_clients.py +``` + +Tested Python, HTTP client and generator combinations are defined in the +[CI workflows](.github/workflows). + +### Code Style + +[![Ruff](https://img.shields.io/badge/lint-Ruff-D7FF64.svg?logo=ruff&logoColor=black)](https://docs.astral.sh/ruff/) +[![mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://www.mypy-lang.org/) + +[Ruff](https://docs.astral.sh/ruff/) checks code quality. +[Mypy](https://www.mypy-lang.org/) checks the package and test scripts in strict +mode. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f43d300 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,65 @@ +[build-system] +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" + +[project] +name = "mastercard-oauth2-client" +version = "1.0.0" +description = "Mastercard OAuth 2.0 client authentication for Python" +readme = "README.md" +requires-python = ">=3.12" +license = { text = "Apache-2.0" } +dependencies = [ + "cryptography>=45.0.1", +] + +[project.optional-dependencies] +dev = [ + "aiohttp>=3.13,<4", + "aiohttp-retry>=2.9,<3", + "build>=1.2.2", + "mypy>=1.17", + "pydantic>=2.11", + "pytest>=8.4", + "python-dateutil>=2.8.2", + "ruff>=0.12", + "twine>=6.1", + "typing-extensions>=4.14", + "urllib3>=2.1,<3", + "httpx>=0.28,<1", +] +httpx = [ + "httpx>=0.28,<1", +] +aiohttp = [ + "aiohttp>=3.13,<4", +] +generator = [ + "openapi-generator-cli[jdk4py]==7.23.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/mastercard_oauth2_client"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--strict-markers --strict-config" +pythonpath = ["src", "tests/generated/openapi", "tests/generated/openapi_httpx", "tests/generated/openapi_asyncio"] +markers = [ + "aiohttp: aiohttp direct and generated-client integration tests", + "generated: OpenAPI-generated client tests", + "httpx: HTTPX direct and generated-client integration tests", + "urllib3: generated urllib3 client tests", +] + +[tool.ruff] +target-version = "py312" +line-length = 120 +extend-exclude = ["tests/generated"] + +[tool.mypy] +python_version = "3.12" +strict = true +files = ["src/mastercard_oauth2_client", "scripts"] +mypy_path = "src" +explicit_package_bases = true \ No newline at end of file diff --git a/scripts/check_wheel.py b/scripts/check_wheel.py new file mode 100644 index 0000000..51ca0e8 --- /dev/null +++ b/scripts/check_wheel.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import subprocess +import sys +import tempfile +import venv +import zipfile +from collections.abc import Iterable +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def main() -> None: + wheel = next((ROOT / "dist").glob("*.whl"), None) + if wheel is None: + raise SystemExit("No wheel found in dist. Run 'python -m build' first.") + + _check_contents(wheel) + _check_install(wheel, "core", absent=("httpx", "aiohttp")) + _check_install(wheel, "httpx", imports=("mastercard_oauth2_client.integrations.httpx",)) + _check_install(wheel, "aiohttp", imports=("mastercard_oauth2_client.integrations.aiohttp",)) + + +def _check_contents(wheel: Path) -> None: + with zipfile.ZipFile(wheel) as archive: + names = set(archive.namelist()) + if "mastercard_oauth2_client/py.typed" not in names: + raise SystemExit("Wheel is missing mastercard_oauth2_client/py.typed") + unexpected = sorted(name for name in names if name.startswith(("tests/", "scripts/"))) + if unexpected: + raise SystemExit(f"Wheel contains development files: {unexpected}") + + +def _check_install( + wheel: Path, + extra: str, + *, + imports: Iterable[str] = ("mastercard_oauth2_client",), + absent: Iterable[str] = (), +) -> None: + with tempfile.TemporaryDirectory(prefix=f"oauth2-wheel-{extra}-") as directory: + environment = Path(directory) + venv.EnvBuilder(with_pip=True).create(environment) + python = environment / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") + requirement = f"{wheel}[{extra}]" if extra != "core" else str(wheel) + subprocess.run([str(python), "-m", "pip", "install", requirement], check=True) + subprocess.run([str(python), "-m", "pip", "check"], check=True) + for module in imports: + subprocess.run([str(python), "-c", f"import {module}"], check=True) + for module in absent: + command = f"import importlib.util; assert importlib.util.find_spec('{module}') is None" + subprocess.run([str(python), "-c", command], check=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_test_clients.py b/scripts/generate_test_clients.py new file mode 100644 index 0000000..433dceb --- /dev/null +++ b/scripts/generate_test_clients.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +import openapi_generator_cli + +ROOT = Path(__file__).resolve().parents[1] +SPECIFICATION = ROOT / "tests" / "resources" / "openapi" / "fake-api.yaml" +GENERATED_ROOT = ROOT / "tests" / "generated" +GENERATED_CLIENTS = ( + (GENERATED_ROOT / "openapi", "generated_test_client", "oauth2-generated-test-client", None), + ( + GENERATED_ROOT / "openapi_httpx", + "generated_httpx_test_client", + "oauth2-generated-httpx-test-client", + "httpx", + ), + ( + GENERATED_ROOT / "openapi_asyncio", + "generated_asyncio_test_client", + "oauth2-generated-asyncio-test-client", + "asyncio", + ), +) + + +def main() -> None: + if not SPECIFICATION.is_file(): + raise SystemExit(f"OpenAPI specification not found: {SPECIFICATION}") + + for output, package_name, project_name, library in GENERATED_CLIENTS: + _generate_client(output, package_name, project_name, library) + + +def _generate_client( + output: Path, + package_name: str, + project_name: str, + library: str | None, +) -> None: + properties = f"packageName={package_name},projectName={project_name},generateSourceCodeOnly=true" + arguments = [ + "generate", + "-i", + str(SPECIFICATION), + "-g", + "python", + "-o", + str(output), + ] + if library is not None: + arguments.extend(("--library", library)) + arguments.extend( + ( + f"--additional-properties={properties}", + "--global-property=apiTests=false,modelTests=false,apiDocs=false,modelDocs=false", + ) + ) + + shutil.rmtree(output, ignore_errors=True) + result = openapi_generator_cli.run(arguments) + if result.returncode != 0: + raise SystemExit(f"OpenAPI generation failed for {package_name}") + (output / f"{package_name}_README.md").unlink(missing_ok=True) + print(f"Generated {package_name}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_tests.py b/scripts/run_tests.py new file mode 100644 index 0000000..2b470e8 --- /dev/null +++ b/scripts/run_tests.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def main() -> None: + subprocess.run([sys.executable, str(ROOT / "scripts" / "generate_test_clients.py")], check=True) + result = subprocess.run([sys.executable, "-m", "pytest", *sys.argv[1:]], cwd=ROOT, check=False) + raise SystemExit(result.returncode) + + +if __name__ == "__main__": + main() diff --git a/src/mastercard_oauth2_client/__init__.py b/src/mastercard_oauth2_client/__init__.py new file mode 100644 index 0000000..0d06c71 --- /dev/null +++ b/src/mastercard_oauth2_client/__init__.py @@ -0,0 +1,60 @@ +from .config import OAuth2Config, SecurityProfile +from .dpop import create_client_assertion, create_resource_dpop_proof, create_token_dpop_proof +from .exceptions import OAuth2ConfigError, OAuth2Error +from .handlers import AsyncOAuth2Handler, OAuth2Handler +from .integrations.openapi import add_oauth2_layer +from .keys import ( + load_jwk_key_pair, + load_pkcs12_private_key, + load_private_key, + validate_dpop_key, + validate_key, +) +from .models import AccessToken, AccessTokenFilter, DPoPKey, HttpRequest, HttpResponse, KeyPair, PrivateKey, PublicKey +from .protocols import ( + AsyncHttpAdapter, + DPoPKeyProvider, + ScopeResolver, + StaticDPoPKeyProvider, + StaticScopeResolver, + SyncHttpAdapter, + TokenStore, +) +from .store import InMemoryTokenStore +from .token import build_token_request, parse_token_response + +__all__ = [ + "AccessToken", + "AccessTokenFilter", + "AsyncHttpAdapter", + "AsyncOAuth2Handler", + "DPoPKey", + "DPoPKeyProvider", + "HttpRequest", + "HttpResponse", + "InMemoryTokenStore", + "KeyPair", + "OAuth2Config", + "OAuth2ConfigError", + "OAuth2Error", + "OAuth2Handler", + "PrivateKey", + "PublicKey", + "ScopeResolver", + "SecurityProfile", + "StaticDPoPKeyProvider", + "StaticScopeResolver", + "SyncHttpAdapter", + "TokenStore", + "add_oauth2_layer", + "build_token_request", + "create_client_assertion", + "create_resource_dpop_proof", + "create_token_dpop_proof", + "load_jwk_key_pair", + "load_pkcs12_private_key", + "load_private_key", + "parse_token_response", + "validate_dpop_key", + "validate_key", +] diff --git a/src/mastercard_oauth2_client/_internal/__init__.py b/src/mastercard_oauth2_client/_internal/__init__.py new file mode 100644 index 0000000..38905d5 --- /dev/null +++ b/src/mastercard_oauth2_client/_internal/__init__.py @@ -0,0 +1 @@ +"""Private implementation modules for mastercard_oauth2_client.""" diff --git a/src/mastercard_oauth2_client/_internal/_clock.py b/src/mastercard_oauth2_client/_internal/_clock.py new file mode 100644 index 0000000..2e400ce --- /dev/null +++ b/src/mastercard_oauth2_client/_internal/_clock.py @@ -0,0 +1,10 @@ +from collections.abc import Callable +from datetime import UTC, datetime + +type NowProvider = Callable[[], datetime] + + +def utc_now() -> datetime: + """Return the current timezone-aware UTC time.""" + + return datetime.now(UTC) diff --git a/src/mastercard_oauth2_client/_internal/_encoding.py b/src/mastercard_oauth2_client/_internal/_encoding.py new file mode 100644 index 0000000..c507d7e --- /dev/null +++ b/src/mastercard_oauth2_client/_internal/_encoding.py @@ -0,0 +1,7 @@ +import base64 + + +def base64url_encode(value: bytes) -> str: + """Encode bytes with unpadded Base64url as required by JOSE.""" + + return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") diff --git a/src/mastercard_oauth2_client/_internal/_scopes.py b/src/mastercard_oauth2_client/_internal/_scopes.py new file mode 100644 index 0000000..8ea8358 --- /dev/null +++ b/src/mastercard_oauth2_client/_internal/_scopes.py @@ -0,0 +1,7 @@ +from collections.abc import Set as AbstractSet + + +def normalize_scopes(scopes: AbstractSet[str]) -> frozenset[str]: + """Return non-empty scopes with surrounding whitespace removed.""" + + return frozenset(scope.strip() for scope in scopes if scope.strip()) diff --git a/src/mastercard_oauth2_client/_internal/http.py b/src/mastercard_oauth2_client/_internal/http.py new file mode 100644 index 0000000..32ac581 --- /dev/null +++ b/src/mastercard_oauth2_client/_internal/http.py @@ -0,0 +1,31 @@ +from collections.abc import Mapping +from urllib.parse import urlsplit + +_HTTP_SUCCESS_MIN = 200 +_HTTP_SUCCESS_MAX_EXCLUSIVE = 300 + + +def is_success_status(status: int) -> bool: + """Return whether an HTTP status is in the successful 2xx range.""" + + return _HTTP_SUCCESS_MIN <= status < _HTTP_SUCCESS_MAX_EXCLUSIVE + + +def is_absolute_https_url(url: str) -> bool: + """Return whether a URL has an HTTPS scheme and network location.""" + + try: + parsed = urlsplit(url) + return parsed.scheme.casefold() == "https" and bool(parsed.netloc) and parsed.hostname is not None + except ValueError: + return False + + +def get_header(headers: Mapping[str, str], name: str) -> str | None: + """Read an HTTP header using case-insensitive field-name matching.""" + + normalized_name = name.casefold() + for header_name, value in headers.items(): + if header_name.casefold() == normalized_name: + return value + return None diff --git a/src/mastercard_oauth2_client/_internal/jose.py b/src/mastercard_oauth2_client/_internal/jose.py new file mode 100644 index 0000000..90d16b0 --- /dev/null +++ b/src/mastercard_oauth2_client/_internal/jose.py @@ -0,0 +1,254 @@ +import base64 +import hashlib +import json +from collections.abc import Mapping +from typing import Literal + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa, utils + +from ..exceptions import OAuth2ConfigError, OAuth2Error +from ..models import KeyPair, PrivateKey, PublicKey +from ._encoding import base64url_encode as _base64url_encode + +type JwsAlgorithm = Literal["PS256", "ES256"] + +_ALGORITHM_PS256: JwsAlgorithm = "PS256" +_ALGORITHM_ES256: JwsAlgorithm = "ES256" +_KEY_TYPE_RSA = "RSA" +_KEY_TYPE_EC = "EC" +_CURVE_P256 = "P-256" + + +def _base64url_uint(value: int, size: int | None = None) -> str: + """Encode an unsigned JWK integer using minimal or fixed-width bytes.""" + + length = size if size is not None else max(1, (value.bit_length() + 7) // 8) + return _base64url_encode(value.to_bytes(length, "big")) + + +def _decode_base64url_uint(value: object) -> int: + """Decode an unsigned Base64url JWK integer.""" + + if not isinstance(value, str) or not value: + raise ValueError("JWK member must be a non-empty string") + encoded = value.encode("ascii") + return int.from_bytes(base64.b64decode(encoded + b"=" * (-len(encoded) % 4), altchars=b"-_", validate=True), "big") + + +def _json_encode(value: Mapping[str, object]) -> bytes: + """Serialize JOSE JSON without insignificant whitespace.""" + + return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + + +def _require_jwk_members( + value: Mapping[str, str | list[str]], + key_type: str, + members: tuple[str, ...], +) -> tuple[str, ...]: + """Return required string members or raise a clear validation error.""" + + member_values: list[str] = [] + for member in members: + member_value = value.get(member) + if not isinstance(member_value, str) or not member_value: + names = ", ".join(members) + raise OAuth2ConfigError(f"Missing required {key_type} JWK parameters ({names})") + member_values.append(member_value) + return tuple(member_values) + + +def _curve_name(curve: ec.EllipticCurve) -> str: + """Return the RFC 7518 JWK name for the supported P-256 curve.""" + + if isinstance(curve, ec.SECP256R1): + return _CURVE_P256 + raise OAuth2ConfigError(f"Unsupported curve: {curve.name}") + + +def algorithm_for_key(key: object) -> JwsAlgorithm: + """Select the supported JOSE signing algorithm for a native key.""" + + if isinstance(key, rsa.RSAPrivateKey | rsa.RSAPublicKey): + return _ALGORITHM_PS256 + if isinstance(key, ec.EllipticCurvePrivateKey | ec.EllipticCurvePublicKey): + return _ALGORITHM_ES256 + raise OAuth2ConfigError(f"Key algorithm must be RSA or EC, but was: {type(key).__name__}") + + +def _import_rsa_jwk(value: Mapping[str, str | list[str]]) -> KeyPair: + """Construct native RSA keys from RFC 7518 private JWK members.""" + + modulus_value, public_exponent_value, private_exponent_value = _require_jwk_members( + value, + _KEY_TYPE_RSA, + ("n", "e", "d"), + ) + modulus = _decode_base64url_uint(modulus_value) + public_exponent = _decode_base64url_uint(public_exponent_value) + private_exponent = _decode_base64url_uint(private_exponent_value) + + # cryptography requires CRT values, so derive them from the minimal n, e and d members. + p, q = rsa.rsa_recover_prime_factors(modulus, public_exponent, private_exponent) + rsa_public_numbers = rsa.RSAPublicNumbers(e=public_exponent, n=modulus) + private_key = rsa.RSAPrivateNumbers( + p=p, + q=q, + d=private_exponent, + dmp1=rsa.rsa_crt_dmp1(private_exponent, p), + dmq1=rsa.rsa_crt_dmq1(private_exponent, q), + iqmp=rsa.rsa_crt_iqmp(p, q), + public_numbers=rsa_public_numbers, + ).private_key() + return KeyPair(private_key=private_key, public_key=private_key.public_key()) + + +def _import_ec_jwk(value: Mapping[str, str | list[str]]) -> KeyPair: + """Construct native P-256 keys from RFC 7518 private JWK members.""" + + curve_name, x_value, y_value, private_value = _require_jwk_members( + value, + _KEY_TYPE_EC, + ("crv", "x", "y", "d"), + ) + if curve_name != _CURVE_P256: + raise OAuth2ConfigError(f"Unsupported curve: {curve_name}") + + x = _decode_base64url_uint(x_value) + y = _decode_base64url_uint(y_value) + d = _decode_base64url_uint(private_value) + ec_public_numbers = ec.EllipticCurvePublicNumbers(x=x, y=y, curve=ec.SECP256R1()) + private_key = ec.EllipticCurvePrivateNumbers( + private_value=d, + public_numbers=ec_public_numbers, + ).private_key() + return KeyPair(private_key=private_key, public_key=private_key.public_key()) + + +def import_jwk_key_pair(value: Mapping[str, str | list[str]]) -> KeyPair: + """Import an RFC 7517 RSA or EC private JWK as native keys.""" + + try: + key_type = value.get("kty") + if key_type == _KEY_TYPE_RSA: + return _import_rsa_jwk(value) + if key_type == _KEY_TYPE_EC: + return _import_ec_jwk(value) + if key_type is None: + raise OAuth2ConfigError("Missing required JWK parameter: kty") + raise OAuth2ConfigError(f"Unsupported key type: {key_type}") + except OAuth2ConfigError: + raise + except (TypeError, UnicodeError, ValueError) as error: + raise OAuth2ConfigError("Unable to load JWK key pair") from error + + +def public_jwk(key: PublicKey) -> dict[str, str]: + """Export the RFC 7517 public JWK members required by DPoP.""" + + if isinstance(key, rsa.RSAPublicKey): + rsa_numbers = key.public_numbers() + return { + "kty": _KEY_TYPE_RSA, + "n": _base64url_uint(rsa_numbers.n), + "e": _base64url_uint(rsa_numbers.e), + } + if isinstance(key, ec.EllipticCurvePublicKey): + curve_name = _curve_name(key.curve) + ec_numbers = key.public_numbers() + component_size = (key.curve.key_size + 7) // 8 + return { + "kty": _KEY_TYPE_EC, + "crv": curve_name, + "x": _base64url_uint(ec_numbers.x, component_size), + "y": _base64url_uint(ec_numbers.y, component_size), + } + raise OAuth2Error(f"Unsupported public key type: {type(key).__name__}") + + +def jwk_thumbprint(key: PublicKey) -> str: + """Return the RFC 7638 SHA-256 thumbprint of a public key.""" + + canonical_jwk = json.dumps(public_jwk(key), separators=(",", ":"), sort_keys=True).encode("ascii") + return _base64url_encode(hashlib.sha256(canonical_jwk).digest()) + + +def _get_signing_input(header: Mapping[str, object], claims: Mapping[str, object]) -> str: + """Build the RFC 7515 protected-header and payload signing input.""" + + encoded_header = _base64url_encode(_json_encode(header)) + encoded_claims = _base64url_encode(_json_encode(claims)) + return f"{encoded_header}.{encoded_claims}" + + +def _sign_ps256(data: bytes, private_key: PrivateKey) -> bytes: + """Sign data using RSASSA-PSS with SHA-256 and a 32-byte salt.""" + + if not isinstance(private_key, rsa.RSAPrivateKey): + raise OAuth2ConfigError("PS256 requires an RSA private key") + return private_key.sign( + data, + padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=32), + hashes.SHA256(), + ) + + +def _encode_p1363_signature(r: int, s: int, component_size: int) -> bytes: + """Encode ECDSA components as the fixed-width P1363 r || s format used by JOSE.""" + + encoded_r = r.to_bytes(component_size, "big") + encoded_s = s.to_bytes(component_size, "big") + return encoded_r + encoded_s + + +def _sign_es256(data: bytes, private_key: PrivateKey) -> bytes: + """Sign data using ECDSA with P-256 and SHA-256, returning P1363 bytes.""" + + if not isinstance(private_key, ec.EllipticCurvePrivateKey): + raise OAuth2ConfigError("ES256 requires an EC private key") + _curve_name(private_key.curve) + + # cryptography returns ASN.1 DER; JOSE requires the P1363 r || s representation. + der_signature = private_key.sign(data, ec.ECDSA(hashes.SHA256())) + r, s = utils.decode_dss_signature(der_signature) + component_size = (private_key.curve.key_size + 7) // 8 + return _encode_p1363_signature(r, s, component_size) + + +def _sign_bytes(data: bytes, private_key: PrivateKey, algorithm: JwsAlgorithm) -> bytes: + """Sign bytes with one of the supported FAPI 2.0 JWS algorithms.""" + + if algorithm == _ALGORITHM_PS256: + return _sign_ps256(data, private_key) + if algorithm == _ALGORITHM_ES256: + return _sign_es256(data, private_key) + raise OAuth2ConfigError(f"Unsupported algorithm: {algorithm}") + + +def _serialize_jwt(signing_input: str, signature: bytes) -> str: + """Create an RFC 7515 compact JWS from its signing input and signature.""" + + if not signature: + raise OAuth2Error("Signature is required") + return f"{signing_input}.{_base64url_encode(signature)}" + + +def sign_jwt( + header: dict[str, object], + claims: dict[str, object], + private_key: PrivateKey, + algorithm: JwsAlgorithm | None = None, +) -> str: + """Create an RFC 7519 JWT using RFC 7515 compact JWS serialization.""" + + selected_algorithm = algorithm or algorithm_for_key(private_key) + protected_header = {**header, "alg": selected_algorithm} + try: + signing_input = _get_signing_input(protected_header, claims) + signature = _sign_bytes(signing_input.encode("ascii"), private_key, selected_algorithm) + return _serialize_jwt(signing_input, signature) + except OAuth2Error: + raise + except Exception as error: + raise OAuth2Error("Unable to sign JWT") from error diff --git a/src/mastercard_oauth2_client/_internal/orchestration.py b/src/mastercard_oauth2_client/_internal/orchestration.py new file mode 100644 index 0000000..a238e72 --- /dev/null +++ b/src/mastercard_oauth2_client/_internal/orchestration.py @@ -0,0 +1,109 @@ +import json +from collections.abc import Mapping +from threading import RLock +from urllib.parse import urlsplit, urlunsplit + +from .._internal.http import get_header, is_absolute_https_url +from .._internal.jose import jwk_thumbprint +from ..config import OAuth2Config +from ..dpop import create_resource_dpop_proof +from ..exceptions import OAuth2Error +from ..models import AccessToken, AccessTokenFilter, DPoPKey, HttpResponse + +_NONCE_CHALLENGE_STATUSES = frozenset({400, 401}) +_AUTHORIZATION_HEADER = "Authorization" +_DPOP_HEADER = "DPoP" +_DPOP_NONCE_HEADER = "DPoP-Nonce" +_USER_AGENT_HEADER = "User-Agent" +_WWW_AUTHENTICATE_HEADER = "WWW-Authenticate" +_USE_DPOP_NONCE = "use_dpop_nonce" +_OAUTH_CONTROLLED_HEADERS = frozenset({_AUTHORIZATION_HEADER.casefold(), _DPOP_HEADER.casefold()}) + + +class NonceStore: + """Store the client-level DPoP nonce shared by token and resource proofs.""" + + def __init__(self) -> None: + self._nonce: str | None = None + self._lock = RLock() + + def get(self) -> str | None: + with self._lock: + return self._nonce + + def update(self, headers: Mapping[str, str]) -> bool: + nonce = get_header(headers, _DPOP_NONCE_HEADER) + if nonce is None or not nonce.strip(): + return False + with self._lock: + self._nonce = nonce + return True + + +def require_resource_url(url: str) -> None: + if not is_absolute_https_url(url): + raise OAuth2Error(f"FAPI 2.0 requires HTTPS for resource server: {url}") + + +def token_filter(scopes: frozenset[str], dpop_key: DPoPKey) -> AccessTokenFilter: + return AccessTokenFilter(scopes=scopes, jkt=jwk_thumbprint(dpop_key.key_pair.public_key)) + + +def build_resource_headers( + config: OAuth2Config, + existing_headers: Mapping[str, str], + method: str, + url: str, + access_token: AccessToken, + dpop_key: DPoPKey, + nonce: str | None, +) -> dict[str, str]: + headers = { + name: value for name, value in existing_headers.items() if name.casefold() not in _OAUTH_CONTROLLED_HEADERS + } + if get_header(headers, _USER_AGENT_HEADER) is None: + headers[_USER_AGENT_HEADER] = config.user_agent + headers[_AUTHORIZATION_HEADER] = f"DPoP {access_token.token_value}" + headers[_DPOP_HEADER] = create_resource_dpop_proof( + config, + dpop_key.key_id, + method, + url, + access_token.token_value, + nonce, + ) + return headers + + +def has_nonce_challenge_header(status: int, headers: Mapping[str, str]) -> bool: + if not is_nonce_challenge_status(status): + return False + authenticate = get_header(headers, _WWW_AUTHENTICATE_HEADER) + return authenticate is not None and _USE_DPOP_NONCE in authenticate.casefold() + + +def has_nonce_challenge_body(status: int, body: bytes | str | None) -> bool: + return is_nonce_challenge_status(status) and _json_error(body) == _USE_DPOP_NONCE + + +def is_nonce_challenge_status(status: int) -> bool: + return status in _NONCE_CHALLENGE_STATUSES + + +def response_snapshot(status: int, headers: Mapping[str, str], body: bytes | str | None) -> HttpResponse: + return HttpResponse(status=status, headers=headers, body=body) + + +def safe_url(url: str) -> str: + parsed = urlsplit(url) + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", "")) + + +def _json_error(body: bytes | str | None) -> object: + if body is None or not body: + return None + try: + value = json.loads(body) + except (json.JSONDecodeError, UnicodeDecodeError): + return None + return value.get("error") if isinstance(value, dict) else None diff --git a/src/mastercard_oauth2_client/config.py b/src/mastercard_oauth2_client/config.py new file mode 100644 index 0000000..1078b0b --- /dev/null +++ b/src/mastercard_oauth2_client/config.py @@ -0,0 +1,103 @@ +import platform +from dataclasses import dataclass, field +from enum import Enum +from importlib.metadata import PackageNotFoundError, version + +from ._internal.http import is_absolute_https_url +from .exceptions import OAuth2ConfigError +from .keys import validate_dpop_key, validate_private_key +from .models import PrivateKey +from .protocols import DPoPKeyProvider, ScopeResolver, TokenStore +from .store import InMemoryTokenStore + +_DISTRIBUTION_NAME = "mastercard-oauth2-client" +_USER_AGENT_PRODUCT = "Mastercard-OAuth2-Client" +_UNKNOWN_VERSION = "0.0.0-unknown" +_DEFAULT_CLOCK_SKEW_SECONDS = 5 + + +class SecurityProfile(Enum): + """OAuth2 security profiles supported by this library.""" + + FAPI2_PRIVATE_KEY_DPOP = "fapi2-private-key-dpop" + + +def _library_version() -> str: + try: + return version(_DISTRIBUTION_NAME) + except PackageNotFoundError: + return _UNKNOWN_VERSION + + +def default_user_agent() -> str: + """Build the default product identifier sent on OAuth2 HTTP requests.""" + + return ( + f"{_USER_AGENT_PRODUCT}/{_library_version()} " + f"(Python/{platform.python_version()}; {platform.system()} {platform.release()})" + ) + + +@dataclass(frozen=True, slots=True) +class OAuth2Config: + """Immutable inputs and extension points for the OAuth2 client.""" + + client_id: str + token_endpoint: str + issuer: str + client_key: PrivateKey + key_id: str + scope_resolver: ScopeResolver + dpop_key_provider: DPoPKeyProvider + security_profile: SecurityProfile = SecurityProfile.FAPI2_PRIVATE_KEY_DPOP + clock_skew_tolerance: int = _DEFAULT_CLOCK_SKEW_SECONDS + token_store: TokenStore = field(default_factory=InMemoryTokenStore) + user_agent: str = field(default_factory=default_user_agent) + + def __post_init__(self) -> None: + self._validate_required_values() + self._validate_extension_points() + self._validate_settings() + self._validate_signing_keys() + + def _validate_required_values(self) -> None: + client_id = _required_string(self.client_id, "Client ID is required") + object.__setattr__(self, "client_id", client_id) + _required_string(self.token_endpoint, "Token endpoint is required") + if not is_absolute_https_url(self.token_endpoint): + raise OAuth2ConfigError("FAPI 2.0 requires HTTPS token endpoint") + _required_string(self.issuer, "Issuer is required") + if self.client_key is None: + raise OAuth2ConfigError("Client private key is required") + key_id = _required_string(self.key_id, "Key ID (kid) is required") + object.__setattr__(self, "key_id", key_id) + + def _validate_extension_points(self) -> None: + if self.scope_resolver is None: + raise OAuth2ConfigError("Scope resolver is required") + if self.dpop_key_provider is None: + raise OAuth2ConfigError("DPoP key provider is required") + if self.token_store is None: + raise OAuth2ConfigError("Token store is required") + + def _validate_settings(self) -> None: + if self.security_profile is not SecurityProfile.FAPI2_PRIVATE_KEY_DPOP: + raise OAuth2ConfigError("Security profile must be FAPI 2.0 with private_key_jwt and DPoP") + if isinstance(self.clock_skew_tolerance, bool) or not isinstance(self.clock_skew_tolerance, int): + raise OAuth2ConfigError("Clock skew tolerance must be an integer") + if self.clock_skew_tolerance < 0: + raise OAuth2ConfigError("Clock skew tolerance must not be negative") + if not isinstance(self.user_agent, str): + raise OAuth2ConfigError("User agent must be a string") + if not self.user_agent.strip(): + object.__setattr__(self, "user_agent", default_user_agent()) + + def _validate_signing_keys(self) -> None: + validate_private_key(self.client_key, context="Client key") + validate_dpop_key(self.dpop_key_provider.get_current_key()) + + +def _required_string(value: object, message: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise OAuth2ConfigError(message) + return value.strip() diff --git a/src/mastercard_oauth2_client/dpop.py b/src/mastercard_oauth2_client/dpop.py new file mode 100644 index 0000000..d8598b5 --- /dev/null +++ b/src/mastercard_oauth2_client/dpop.py @@ -0,0 +1,149 @@ +import hashlib +import secrets +from collections.abc import Callable +from urllib.parse import urlsplit, urlunsplit + +from ._internal._clock import NowProvider +from ._internal._clock import utc_now as _utc_now +from ._internal._encoding import base64url_encode as _base64url_encode +from ._internal.jose import algorithm_for_key, public_jwk, sign_jwt +from .config import OAuth2Config +from .exceptions import OAuth2Error +from .keys import validate_dpop_key + +type JtiProvider = Callable[[], str] + +_PROOF_LIFETIME_SECONDS = 90 + + +def _random_jti() -> str: + """Generate the 96-bit random jti required by RFC 9449 section 4.2.""" + + return _base64url_encode(secrets.token_bytes(12)) + + +def _canonicalize_htu(url: str) -> str: + """Build the RFC 9449 htu value without query and fragment components.""" + + parsed = urlsplit(url) + if not parsed.scheme or not parsed.hostname: + raise OAuth2Error("Invalid URL for DPoP htu claim") + + hostname = parsed.hostname.lower() + if ":" in hostname: + hostname = f"[{hostname}]" + authority = hostname if parsed.port is None else f"{hostname}:{parsed.port}" + return urlunsplit((parsed.scheme.lower(), authority, parsed.path, "", "")) + + +def _access_token_hash(access_token: str) -> str: + """Compute the RFC 9449 section 4.2 ath claim value.""" + + digest = hashlib.sha256(access_token.encode("utf-8")).digest() + return _base64url_encode(digest) + + +def create_client_assertion(config: OAuth2Config) -> str: + """Create a private_key_jwt client assertion for the token endpoint.""" + + return _create_client_assertion(config, now_provider=_utc_now, jti_provider=_random_jti) + + +def _create_client_assertion( + config: OAuth2Config, + *, + now_provider: NowProvider, + jti_provider: JtiProvider, +) -> str: + issued_at = int(now_provider().timestamp()) + skew = config.clock_skew_tolerance + header: dict[str, object] = {"typ": "JWT", "kid": config.key_id} + claims: dict[str, object] = { + "iss": config.client_id, + "sub": config.client_id, + "aud": config.issuer, + "jti": jti_provider(), + "iat": issued_at, + "nbf": issued_at - skew, + "exp": issued_at + _PROOF_LIFETIME_SECONDS + skew, + } + return sign_jwt(header, claims, config.client_key) + + +def create_token_dpop_proof( + config: OAuth2Config, + dpop_key_id: str, + nonce: str | None = None, +) -> str: + """Create an RFC 9449 DPoP proof for a token endpoint request.""" + + return _create_dpop_proof( + config, + dpop_key_id, + "POST", + config.token_endpoint, + nonce=nonce, + now_provider=_utc_now, + jti_provider=_random_jti, + ) + + +def create_resource_dpop_proof( + config: OAuth2Config, + dpop_key_id: str, + method: str, + url: str, + access_token: str, + nonce: str | None = None, +) -> str: + """Create an RFC 9449 DPoP proof bound to a resource request and token.""" + + return _create_dpop_proof( + config, + dpop_key_id, + method, + url, + access_token=access_token, + nonce=nonce, + now_provider=_utc_now, + jti_provider=_random_jti, + ) + + +def _create_dpop_proof( + config: OAuth2Config, + dpop_key_id: str, + method: str, + url: str, + *, + access_token: str | None = None, + nonce: str | None = None, + now_provider: NowProvider, + jti_provider: JtiProvider, +) -> str: + dpop_key = config.dpop_key_provider.get_key(dpop_key_id) + validate_dpop_key(dpop_key) + issued_at = int(now_provider().timestamp()) + private_key = dpop_key.key_pair.private_key + public_key = dpop_key.key_pair.public_key + algorithm = algorithm_for_key(public_key) + + header: dict[str, object] = { + "typ": "dpop+jwt", + "alg": algorithm, + "kid": dpop_key_id, + "jwk": public_jwk(public_key), + } + claims: dict[str, object] = { + "jti": jti_provider(), + "htm": method.upper(), + "htu": _canonicalize_htu(url), + "iat": issued_at, + "exp": issued_at + _PROOF_LIFETIME_SECONDS + config.clock_skew_tolerance, + } + if access_token is not None: + claims["ath"] = _access_token_hash(access_token) + if nonce is not None: + claims["nonce"] = nonce + + return sign_jwt(header, claims, private_key, algorithm) diff --git a/src/mastercard_oauth2_client/exceptions.py b/src/mastercard_oauth2_client/exceptions.py new file mode 100644 index 0000000..b92832d --- /dev/null +++ b/src/mastercard_oauth2_client/exceptions.py @@ -0,0 +1,6 @@ +class OAuth2Error(Exception): + """Base exception for OAuth2 failures produced by this library.""" + + +class OAuth2ConfigError(OAuth2Error, ValueError): + """Raised when OAuth2 configuration is invalid.""" diff --git a/src/mastercard_oauth2_client/handlers/__init__.py b/src/mastercard_oauth2_client/handlers/__init__.py new file mode 100644 index 0000000..b6584eb --- /dev/null +++ b/src/mastercard_oauth2_client/handlers/__init__.py @@ -0,0 +1,4 @@ +from .async_ import AsyncOAuth2Handler +from .sync import OAuth2Handler + +__all__ = ["AsyncOAuth2Handler", "OAuth2Handler"] diff --git a/src/mastercard_oauth2_client/handlers/async_.py b/src/mastercard_oauth2_client/handlers/async_.py new file mode 100644 index 0000000..89b91f0 --- /dev/null +++ b/src/mastercard_oauth2_client/handlers/async_.py @@ -0,0 +1,178 @@ +import logging +from collections.abc import Mapping + +from .._internal._scopes import normalize_scopes +from .._internal.http import is_success_status +from .._internal.orchestration import ( + NonceStore, + build_resource_headers, + has_nonce_challenge_body, + has_nonce_challenge_header, + is_nonce_challenge_status, + require_resource_url, + response_snapshot, + safe_url, + token_filter, +) +from ..config import OAuth2Config +from ..keys import validate_dpop_key +from ..models import AccessToken, DPoPKey, HttpResponse +from ..protocols import AsyncHttpAdapter +from ..token import build_token_request, parse_token_response + +_LOGGER = logging.getLogger(__name__) + + +class AsyncOAuth2Handler[RequestT, ResponseT]: + """Coordinate asynchronous token acquisition and DPoP resource requests.""" + + def __init__(self, config: OAuth2Config) -> None: + self._config = config + self._nonces = NonceStore() + + async def execute(self, request: RequestT, adapter: AsyncHttpAdapter[RequestT, ResponseT]) -> ResponseT: + """Authenticate and execute one resource request, retrying nonce challenges once.""" + + method = adapter.request_method(request) + url = adapter.request_url(request) + require_resource_url(url) + _LOGGER.info("Authenticating resource request: %s %s", method.upper(), safe_url(url)) + _LOGGER.info("Resolving scopes for resource request") + scopes = normalize_scopes(self._config.scope_resolver.resolve(method, url)) + _LOGGER.debug("Resolved %d scopes", len(scopes)) + + _LOGGER.info("Retrieving DPoP key") + dpop_key = self._config.dpop_key_provider.get_current_key() + validate_dpop_key(dpop_key) + _LOGGER.debug("DPoP key selected") + access_token = await self._get_access_token(request, adapter, scopes, dpop_key) + if not isinstance(access_token, AccessToken): + # Token acquisition failed, so return the native token-endpoint response to the caller. + return access_token + + response = await self._send_resource_request(request, adapter, access_token, dpop_key) + _LOGGER.info("Resource request completed with HTTP %d", adapter.response_status(response)) + return response + + async def _get_access_token( + self, + resource_request: RequestT, + adapter: AsyncHttpAdapter[RequestT, ResponseT], + scopes: frozenset[str], + dpop_key: DPoPKey, + ) -> AccessToken | ResponseT: + """Return a usable token, or the failed native token-endpoint response.""" + + access_token = self._config.token_store.get(token_filter(scopes, dpop_key)) + if access_token is not None: + _LOGGER.info("Access token cache hit") + return access_token + + _LOGGER.info("Access token cache miss; requesting token") + response = await self._send_token_request_with_retry(resource_request, adapter, scopes, dpop_key) + status = adapter.response_status(response) + _LOGGER.info("Token request completed with HTTP %d", status) + if not is_success_status(status): + return response + + try: + snapshot = await self._response_snapshot(adapter, response) + finally: + await adapter.close_response(response) + access_token = parse_token_response(self._config, snapshot, scopes, dpop_key.key_id) + self._config.token_store.put(access_token) + _LOGGER.info("Access token stored") + return access_token + + async def _response_snapshot( + self, + adapter: AsyncHttpAdapter[RequestT, ResponseT], + response: ResponseT, + ) -> HttpResponse: + """Read a native token response into a detached neutral snapshot.""" + + return response_snapshot( + adapter.response_status(response), + adapter.response_headers(response), + await adapter.response_body(response), + ) + + async def _send_token_request_with_retry( + self, + resource_request: RequestT, + adapter: AsyncHttpAdapter[RequestT, ResponseT], + scopes: frozenset[str], + dpop_key: DPoPKey, + ) -> ResponseT: + """Send a token request and retry one nonce challenge with a fresh proof.""" + + request = build_token_request(self._config, scopes, dpop_key.key_id, self._nonces.get()) + response = await adapter.send_token_request(resource_request, request) + self._update_nonce(adapter.response_headers(response)) + if await self._is_nonce_challenge(adapter, response): + await adapter.close_response(response) + _LOGGER.info("DPoP nonce-directed retry for token request") + retry_request = build_token_request(self._config, scopes, dpop_key.key_id, self._nonces.get()) + response = await adapter.send_token_request(resource_request, retry_request) + self._update_nonce(adapter.response_headers(response)) + return response + + async def _send_resource_request( + self, + request: RequestT, + adapter: AsyncHttpAdapter[RequestT, ResponseT], + access_token: AccessToken, + dpop_key: DPoPKey, + ) -> ResponseT: + """Send a resource request and retry one nonce challenge with a fresh proof.""" + + headers = self._resource_headers(request, adapter, access_token, dpop_key) + response = await adapter.send_resource_request(request, headers) + self._update_nonce(adapter.response_headers(response)) + if await self._is_nonce_challenge(adapter, response): + await adapter.close_response(response) + _LOGGER.info("DPoP nonce-directed retry for resource request") + retry_headers = self._resource_headers(request, adapter, access_token, dpop_key) + response = await adapter.send_resource_request(request, retry_headers) + self._update_nonce(adapter.response_headers(response)) + return response + + def _resource_headers( + self, + request: RequestT, + adapter: AsyncHttpAdapter[RequestT, ResponseT], + access_token: AccessToken, + dpop_key: DPoPKey, + ) -> dict[str, str]: + """Build OAuth2 headers without copying or serializing the native request.""" + + return build_resource_headers( + self._config, + adapter.request_headers(request), + adapter.request_method(request), + adapter.request_url(request), + access_token, + dpop_key, + self._nonces.get(), + ) + + def _update_nonce(self, headers: Mapping[str, str]) -> None: + """Store a non-empty DPoP nonce returned in response headers.""" + + if self._nonces.update(headers): + _LOGGER.debug("DPoP nonce received") + + @staticmethod + async def _is_nonce_challenge( + adapter: AsyncHttpAdapter[RequestT, ResponseT], + response: ResponseT, + ) -> bool: + """Detect a nonce challenge without reading successful response bodies.""" + + status = adapter.response_status(response) + headers = adapter.response_headers(response) + if has_nonce_challenge_header(status, headers): + return True + if not is_nonce_challenge_status(status): + return False + return has_nonce_challenge_body(status, await adapter.response_body(response)) diff --git a/src/mastercard_oauth2_client/handlers/sync.py b/src/mastercard_oauth2_client/handlers/sync.py new file mode 100644 index 0000000..78d18f0 --- /dev/null +++ b/src/mastercard_oauth2_client/handlers/sync.py @@ -0,0 +1,175 @@ +import logging +from collections.abc import Mapping + +from .._internal._scopes import normalize_scopes +from .._internal.http import is_success_status +from .._internal.orchestration import ( + NonceStore, + build_resource_headers, + has_nonce_challenge_body, + has_nonce_challenge_header, + is_nonce_challenge_status, + require_resource_url, + response_snapshot, + safe_url, + token_filter, +) +from ..config import OAuth2Config +from ..keys import validate_dpop_key +from ..models import AccessToken, DPoPKey, HttpResponse +from ..protocols import SyncHttpAdapter +from ..token import build_token_request, parse_token_response + +_LOGGER = logging.getLogger(__name__) + + +class OAuth2Handler[RequestT, ResponseT]: + """Coordinate synchronous token acquisition and DPoP resource requests.""" + + def __init__(self, config: OAuth2Config) -> None: + self._config = config + self._nonces = NonceStore() + + def execute(self, request: RequestT, adapter: SyncHttpAdapter[RequestT, ResponseT]) -> ResponseT: + """Authenticate and execute one resource request, retrying nonce challenges once.""" + + method = adapter.request_method(request) + url = adapter.request_url(request) + require_resource_url(url) + _LOGGER.info("Authenticating resource request: %s %s", method.upper(), safe_url(url)) + _LOGGER.info("Resolving scopes for resource request") + scopes = normalize_scopes(self._config.scope_resolver.resolve(method, url)) + _LOGGER.debug("Resolved %d scopes", len(scopes)) + + _LOGGER.info("Retrieving DPoP key") + dpop_key = self._config.dpop_key_provider.get_current_key() + validate_dpop_key(dpop_key) + _LOGGER.debug("DPoP key selected") + access_token = self._get_access_token(request, adapter, scopes, dpop_key) + if not isinstance(access_token, AccessToken): + # Token acquisition failed, so return the native token-endpoint response to the caller. + return access_token + + response = self._send_resource_request(request, adapter, access_token, dpop_key) + _LOGGER.info("Resource request completed with HTTP %d", adapter.response_status(response)) + return response + + def _get_access_token( + self, + resource_request: RequestT, + adapter: SyncHttpAdapter[RequestT, ResponseT], + scopes: frozenset[str], + dpop_key: DPoPKey, + ) -> AccessToken | ResponseT: + """Return a usable token, or the failed native token-endpoint response.""" + + access_token = self._config.token_store.get(token_filter(scopes, dpop_key)) + if access_token is not None: + _LOGGER.info("Access token cache hit") + return access_token + + _LOGGER.info("Access token cache miss; requesting token") + response = self._send_token_request_with_retry(resource_request, adapter, scopes, dpop_key) + status = adapter.response_status(response) + _LOGGER.info("Token request completed with HTTP %d", status) + if not is_success_status(status): + return response + + try: + snapshot = self._response_snapshot(adapter, response) + finally: + adapter.close_response(response) + access_token = parse_token_response(self._config, snapshot, scopes, dpop_key.key_id) + self._config.token_store.put(access_token) + _LOGGER.info("Access token stored") + return access_token + + def _send_token_request_with_retry( + self, + resource_request: RequestT, + adapter: SyncHttpAdapter[RequestT, ResponseT], + scopes: frozenset[str], + dpop_key: DPoPKey, + ) -> ResponseT: + """Send a token request and retry one nonce challenge with a fresh proof.""" + + request = build_token_request(self._config, scopes, dpop_key.key_id, self._nonces.get()) + response = adapter.send_token_request(resource_request, request) + self._update_nonce(adapter.response_headers(response)) + if self._is_nonce_challenge(adapter, response): + adapter.close_response(response) + _LOGGER.info("DPoP nonce-directed retry for token request") + retry_request = build_token_request(self._config, scopes, dpop_key.key_id, self._nonces.get()) + response = adapter.send_token_request(resource_request, retry_request) + self._update_nonce(adapter.response_headers(response)) + return response + + def _send_resource_request( + self, + request: RequestT, + adapter: SyncHttpAdapter[RequestT, ResponseT], + access_token: AccessToken, + dpop_key: DPoPKey, + ) -> ResponseT: + """Send a resource request and retry one nonce challenge with a fresh proof.""" + + headers = self._resource_headers(request, adapter, access_token, dpop_key) + response = adapter.send_resource_request(request, headers) + self._update_nonce(adapter.response_headers(response)) + if self._is_nonce_challenge(adapter, response): + adapter.close_response(response) + _LOGGER.info("DPoP nonce-directed retry for resource request") + retry_headers = self._resource_headers(request, adapter, access_token, dpop_key) + response = adapter.send_resource_request(request, retry_headers) + self._update_nonce(adapter.response_headers(response)) + return response + + def _response_snapshot( + self, + adapter: SyncHttpAdapter[RequestT, ResponseT], + response: ResponseT, + ) -> HttpResponse: + """Read a native token response into a detached neutral snapshot.""" + + return response_snapshot( + adapter.response_status(response), + adapter.response_headers(response), + adapter.response_body(response), + ) + + def _resource_headers( + self, + request: RequestT, + adapter: SyncHttpAdapter[RequestT, ResponseT], + access_token: AccessToken, + dpop_key: DPoPKey, + ) -> dict[str, str]: + """Build OAuth2 headers without copying or serializing the native request.""" + + return build_resource_headers( + self._config, + adapter.request_headers(request), + adapter.request_method(request), + adapter.request_url(request), + access_token, + dpop_key, + self._nonces.get(), + ) + + def _update_nonce(self, headers: Mapping[str, str]) -> None: + """Store a non-empty DPoP nonce returned in response headers.""" + + if self._nonces.update(headers): + _LOGGER.debug("DPoP nonce received") + + @staticmethod + def _is_nonce_challenge(adapter: SyncHttpAdapter[RequestT, ResponseT], response: ResponseT) -> bool: + """Detect a nonce challenge without reading successful response bodies.""" + + status = adapter.response_status(response) + headers = adapter.response_headers(response) + if has_nonce_challenge_header(status, headers): + return True + if not is_nonce_challenge_status(status): + return False + return has_nonce_challenge_body(status, adapter.response_body(response)) diff --git a/src/mastercard_oauth2_client/integrations/__init__.py b/src/mastercard_oauth2_client/integrations/__init__.py new file mode 100644 index 0000000..47b0b6c --- /dev/null +++ b/src/mastercard_oauth2_client/integrations/__init__.py @@ -0,0 +1 @@ +"""Optional integrations for concrete HTTP clients.""" diff --git a/src/mastercard_oauth2_client/integrations/aiohttp.py b/src/mastercard_oauth2_client/integrations/aiohttp.py new file mode 100644 index 0000000..5c1ba4e --- /dev/null +++ b/src/mastercard_oauth2_client/integrations/aiohttp.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from collections.abc import Mapping + +import aiohttp + +from ..config import OAuth2Config +from ..handlers import AsyncOAuth2Handler +from ..models import HttpRequest + + +class _AiohttpAdapter: + """Map native aiohttp requests and responses into handler operations.""" + + def __init__(self, resource_handler: aiohttp.ClientHandlerType) -> None: + self._resource_handler = resource_handler + + def request_method(self, request: aiohttp.ClientRequest) -> str: + return request.method + + def request_url(self, request: aiohttp.ClientRequest) -> str: + return str(request.url) + + def request_headers(self, request: aiohttp.ClientRequest) -> Mapping[str, str]: + return request.headers + + async def send_token_request( + self, + resource_request: aiohttp.ClientRequest, + request: HttpRequest, + ) -> aiohttp.ClientResponse: + return await resource_request.session.request( + request.method, + request.url, + headers=request.headers, + data=request.body, + ssl=resource_request.ssl, + proxy=resource_request.proxy, + proxy_headers=resource_request.proxy_headers, + raise_for_status=False, + middlewares=(), + ) + + async def send_resource_request( + self, + request: aiohttp.ClientRequest, + headers: Mapping[str, str], + ) -> aiohttp.ClientResponse: + request.headers.clear() + request.headers.update(headers) + return await self._resource_handler(request) + + def response_status(self, response: aiohttp.ClientResponse) -> int: + return response.status + + def response_headers(self, response: aiohttp.ClientResponse) -> Mapping[str, str]: + return response.headers + + async def response_body(self, response: aiohttp.ClientResponse) -> bytes: + return await response.read() + + async def close_response(self, response: aiohttp.ClientResponse) -> None: + await response.read() + response.release() + + +class OAuth2Middleware: + """Authenticate aiohttp client requests with OAuth2 and DPoP.""" + + def __init__(self, config: OAuth2Config) -> None: + self._handler = AsyncOAuth2Handler[aiohttp.ClientRequest, aiohttp.ClientResponse](config) + + async def __call__( + self, + request: aiohttp.ClientRequest, + handler: aiohttp.ClientHandlerType, + ) -> aiohttp.ClientResponse: + """Buffer a replayable request and execute the OAuth2 flow.""" + + if isinstance(request.body, aiohttp.payload.Payload): + body = await request.body.as_bytes() + request.chunked = None + await request.update_body(body) + return await self._handler.execute(request, _AiohttpAdapter(handler)) diff --git a/src/mastercard_oauth2_client/integrations/httpx.py b/src/mastercard_oauth2_client/integrations/httpx.py new file mode 100644 index 0000000..65faf68 --- /dev/null +++ b/src/mastercard_oauth2_client/integrations/httpx.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from collections.abc import Mapping + +import httpx + +from ..config import OAuth2Config +from ..handlers import AsyncOAuth2Handler, OAuth2Handler +from ..models import HttpRequest + + +class _HttpxAdapterBase: + """Map native HTTPX request and response metadata into handler operations.""" + + def request_method(self, request: httpx.Request) -> str: + return request.method + + def request_url(self, request: httpx.Request) -> str: + return str(request.url) + + def request_headers(self, request: httpx.Request) -> Mapping[str, str]: + return request.headers + + def response_status(self, response: httpx.Response) -> int: + return response.status_code + + def response_headers(self, response: httpx.Response) -> Mapping[str, str]: + return response.headers + + @staticmethod + def _token_request(resource_request: httpx.Request, request: HttpRequest) -> httpx.Request: + return httpx.Request( + method=request.method, + url=request.url, + headers=request.headers, + content=request.body, + extensions=dict(resource_request.extensions), + ) + + @staticmethod + def _authenticate_request(request: httpx.Request, headers: Mapping[str, str]) -> httpx.Request: + # Mutate only OAuth-controlled headers on the buffered native request so + # HTTPX extensions, URL, body stream and caller-visible request identity remain intact. + request.headers.clear() + request.headers.update(headers) + return request + + +class _SyncHttpxAdapter(_HttpxAdapterBase): + """Execute synchronous HTTPX handler operations through a native transport.""" + + def __init__(self, transport: httpx.BaseTransport) -> None: + self._transport = transport + + def send_token_request(self, resource_request: httpx.Request, request: HttpRequest) -> httpx.Response: + return self._transport.handle_request(self._token_request(resource_request, request)) + + def send_resource_request(self, request: httpx.Request, headers: Mapping[str, str]) -> httpx.Response: + return self._transport.handle_request(self._authenticate_request(request, headers)) + + def response_body(self, response: httpx.Response) -> bytes: + return response.read() + + def close_response(self, response: httpx.Response) -> None: + response.read() + response.close() + + +class _AsyncHttpxAdapter(_HttpxAdapterBase): + """Execute asynchronous HTTPX handler operations through a native transport.""" + + def __init__(self, transport: httpx.AsyncBaseTransport) -> None: + self._transport = transport + + async def send_token_request(self, resource_request: httpx.Request, request: HttpRequest) -> httpx.Response: + return await self._transport.handle_async_request(self._token_request(resource_request, request)) + + async def send_resource_request(self, request: httpx.Request, headers: Mapping[str, str]) -> httpx.Response: + return await self._transport.handle_async_request(self._authenticate_request(request, headers)) + + async def response_body(self, response: httpx.Response) -> bytes: + return await response.aread() + + async def close_response(self, response: httpx.Response) -> None: + await response.aread() + await response.aclose() + + +class OAuth2Transport(httpx.BaseTransport): + """Authenticate requests while delegating network I/O to an HTTPX transport.""" + + def __init__(self, config: OAuth2Config, transport: httpx.BaseTransport | None = None) -> None: + self._transport = transport or httpx.HTTPTransport() + self._adapter = _SyncHttpxAdapter(self._transport) + self._handler = OAuth2Handler[httpx.Request, httpx.Response](config) + + def handle_request(self, request: httpx.Request) -> httpx.Response: + """Buffer a replayable request and execute the OAuth2 flow.""" + + request.read() + return self._handler.execute(request, self._adapter) + + def close(self) -> None: + """Close the wrapped HTTPX transport.""" + + self._transport.close() + + +class AsyncOAuth2Transport(httpx.AsyncBaseTransport): + """Authenticate async requests while delegating I/O to an HTTPX transport.""" + + def __init__(self, config: OAuth2Config, transport: httpx.AsyncBaseTransport | None = None) -> None: + self._transport = transport or httpx.AsyncHTTPTransport() + self._adapter = _AsyncHttpxAdapter(self._transport) + self._handler = AsyncOAuth2Handler[httpx.Request, httpx.Response](config) + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + """Buffer a replayable request and execute the asynchronous OAuth2 flow.""" + + await request.aread() + return await self._handler.execute(request, self._adapter) + + async def aclose(self) -> None: + """Close the wrapped asynchronous HTTPX transport.""" + + await self._transport.aclose() diff --git a/src/mastercard_oauth2_client/integrations/openapi/__init__.py b/src/mastercard_oauth2_client/integrations/openapi/__init__.py new file mode 100644 index 0000000..f555feb --- /dev/null +++ b/src/mastercard_oauth2_client/integrations/openapi/__init__.py @@ -0,0 +1,5 @@ +"""Integrations for clients generated by OpenAPI Generator.""" + +from .urllib3 import add_oauth2_layer + +__all__ = ["add_oauth2_layer"] diff --git a/src/mastercard_oauth2_client/integrations/openapi/asyncio.py b/src/mastercard_oauth2_client/integrations/openapi/asyncio.py new file mode 100644 index 0000000..c4ac8d9 --- /dev/null +++ b/src/mastercard_oauth2_client/integrations/openapi/asyncio.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any, Protocol, cast + +import aiohttp + +from ...config import OAuth2Config +from ...exceptions import OAuth2Error +from ..aiohttp import OAuth2Middleware + + +class _AsyncioConfiguration(Protocol): + client_session_kwargs: dict[str, Any] | None + + +class _AsyncioRestClient(Protocol): + @property + def configuration(self) -> _AsyncioConfiguration: ... + + @property + def pool_manager(self) -> aiohttp.ClientSession | None: ... + + +class _ApiClient(Protocol): + @property + def rest_client(self) -> _AsyncioRestClient: ... + + +def add_oauth2_layer(api_client: _ApiClient, config: OAuth2Config) -> None: + """Authenticate a generated OpenAPI asyncio client through aiohttp middleware.""" + + rest_client = api_client.rest_client + if rest_client.pool_manager is not None: + raise OAuth2Error("Attach OAuth2 before the generated asyncio client sends its first request") + + session_kwargs = dict(rest_client.configuration.client_session_kwargs or {}) + existing_middlewares = cast(Sequence[aiohttp.ClientMiddlewareType], session_kwargs.get("middlewares") or ()) + if any(isinstance(middleware, OAuth2Middleware) for middleware in existing_middlewares): + raise OAuth2Error("OAuth2 is already attached to the generated asyncio client") + session_kwargs["middlewares"] = (OAuth2Middleware(config), *existing_middlewares) + rest_client.configuration.client_session_kwargs = session_kwargs diff --git a/src/mastercard_oauth2_client/integrations/openapi/httpx.py b/src/mastercard_oauth2_client/integrations/openapi/httpx.py new file mode 100644 index 0000000..498004e --- /dev/null +++ b/src/mastercard_oauth2_client/integrations/openapi/httpx.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import ssl +from collections.abc import Mapping +from typing import Protocol + +import httpx + +from ...config import OAuth2Config +from ...exceptions import OAuth2Error +from ..httpx import AsyncOAuth2Transport + + +class _HttpxRestClient(Protocol): + pool_manager: httpx.AsyncClient | None + + @property + def maxsize(self) -> int: ... + + @property + def ssl_context(self) -> ssl.SSLContext: ... + + @property + def proxy(self) -> str | None: ... + + @property + def proxy_headers(self) -> Mapping[str, str] | None: ... + + +class _ApiClient(Protocol): + @property + def rest_client(self) -> _HttpxRestClient: ... + + +def add_oauth2_layer( + api_client: _ApiClient, + config: OAuth2Config, + transport: httpx.AsyncBaseTransport | None = None, +) -> None: + """Authenticate a generated OpenAPI HTTPX client using native async transport.""" + + rest_client = api_client.rest_client + if rest_client.pool_manager is not None: + raise OAuth2Error("Attach OAuth2 before the generated HTTPX client sends its first request") + + base_transport = transport or _generated_transport(rest_client) + rest_client.pool_manager = httpx.AsyncClient( + transport=AsyncOAuth2Transport(config, base_transport), + ) + + +def _generated_transport(rest_client: _HttpxRestClient) -> httpx.AsyncHTTPTransport: + """Build the transport represented by OpenAPI Generator's HTTPX settings.""" + + proxy = None + if rest_client.proxy: + proxy = httpx.Proxy(url=rest_client.proxy, headers=rest_client.proxy_headers) + return httpx.AsyncHTTPTransport( + verify=rest_client.ssl_context, + proxy=proxy, + limits=httpx.Limits(max_connections=rest_client.maxsize), + trust_env=True, + ) diff --git a/src/mastercard_oauth2_client/integrations/openapi/urllib3.py b/src/mastercard_oauth2_client/integrations/openapi/urllib3.py new file mode 100644 index 0000000..0e8d841 --- /dev/null +++ b/src/mastercard_oauth2_client/integrations/openapi/urllib3.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import json +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from functools import wraps +from typing import Any, Protocol +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +from ...config import OAuth2Config +from ...exceptions import OAuth2Error +from ...handlers import OAuth2Handler +from ...models import HttpRequest + +_OAUTH2_ATTACHED = "_mastercard_oauth2_attached" + + +class _RawResponse(Protocol): + def release_conn(self) -> None: ... + + +class _GeneratedResponse(Protocol): + status: int + response: _RawResponse + + def read(self) -> bytes | str | None: ... + + def getheaders(self) -> Mapping[str, str]: ... + + +type GeneratedRequest = Callable[..., _GeneratedResponse] + + +class _RestClient(Protocol): + request: GeneratedRequest + + +class _ApiClient(Protocol): + rest_client: _RestClient + + +@dataclass(frozen=True, slots=True) +class _OpenApiRequest: + method: str + url: str + headers: Mapping[str, str] + body: object | None + post_params: object | None + request_timeout: object | None + + +class _OpenApiAdapter: + def __init__(self, request: GeneratedRequest) -> None: + self._request = request + + def request_method(self, request: _OpenApiRequest) -> str: + return request.method + + def request_url(self, request: _OpenApiRequest) -> str: + return request.url + + def request_headers(self, request: _OpenApiRequest) -> Mapping[str, str]: + return request.headers + + def send_token_request(self, resource_request: _OpenApiRequest, request: HttpRequest) -> _GeneratedResponse: + body, post_params = _generated_body(request) + return self._send( + method=request.method, + url=request.url, + headers=request.headers, + body=body, + post_params=post_params, + request_timeout=resource_request.request_timeout, + ) + + def send_resource_request(self, request: _OpenApiRequest, headers: Mapping[str, str]) -> _GeneratedResponse: + return self._send( + method=request.method, + url=request.url, + headers=headers, + body=request.body, + post_params=request.post_params, + request_timeout=request.request_timeout, + ) + + def _send( + self, + *, + method: str, + url: str, + headers: Mapping[str, str], + body: object | None, + post_params: object | None, + request_timeout: object | None, + ) -> _GeneratedResponse: + return self._request( + method, + url, + headers=dict(headers), + body=body, + post_params=post_params, + _request_timeout=request_timeout, + ) + + def response_status(self, response: _GeneratedResponse) -> int: + return response.status + + def response_headers(self, response: _GeneratedResponse) -> Mapping[str, str]: + return _response_headers(response) + + def response_body(self, response: _GeneratedResponse) -> bytes | str | None: + return response.read() + + def close_response(self, response: _GeneratedResponse) -> None: + response.response.release_conn() + + +def add_oauth2_layer(api_client: _ApiClient, config: OAuth2Config) -> None: + """Authenticate calls made by a synchronous generated OpenAPI Python client.""" + + original_request = api_client.rest_client.request + if getattr(original_request, _OAUTH2_ATTACHED, False): + raise OAuth2Error("OAuth2 is already attached to the generated urllib3 client") + adapter = _OpenApiAdapter(original_request) + handler = OAuth2Handler[_OpenApiRequest, _GeneratedResponse](config) + + @wraps(original_request) + def authenticated_request(method: str, url: str, **kwargs: Any) -> _GeneratedResponse: + return handler.execute( + _OpenApiRequest( + method=method, + url=_url_with_query(url, kwargs.get("query_params")), + headers=dict(kwargs.get("headers") or {}), + body=kwargs.get("body"), + post_params=kwargs.get("post_params"), + request_timeout=kwargs.get("_request_timeout"), + ), + adapter, + ) + + setattr(authenticated_request, _OAUTH2_ATTACHED, True) + api_client.rest_client.request = authenticated_request + + +def _url_with_query(url: str, query_params: object) -> str: + if not query_params: + return url + parsed = urlsplit(url) + encoded_query = urlencode(query_params, doseq=True) # type: ignore[arg-type] + query = "&".join(part for part in (parsed.query, encoded_query) if part) + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, query, parsed.fragment)) + + +def _response_headers(response: _GeneratedResponse) -> dict[str, str]: + return {str(name).casefold(): str(value) for name, value in response.getheaders().items()} + + +def _generated_body(request: HttpRequest) -> tuple[object | None, list[tuple[str, str]] | None]: + content_type = _content_type(request.headers) + if content_type == "application/x-www-form-urlencoded" and isinstance(request.body, str): + return None, parse_qsl(request.body, keep_blank_values=True) + if "json" in content_type and isinstance(request.body, str): + return json.loads(request.body), None + return request.body, None + + +def _content_type(headers: Mapping[str, str]) -> str: + return next((value.casefold() for name, value in headers.items() if name.casefold() == "content-type"), "") diff --git a/src/mastercard_oauth2_client/keys.py b/src/mastercard_oauth2_client/keys.py new file mode 100644 index 0000000..ec49590 --- /dev/null +++ b/src/mastercard_oauth2_client/keys.py @@ -0,0 +1,122 @@ +import json +from collections.abc import Mapping +from os import PathLike +from pathlib import Path + +from cryptography.exceptions import UnsupportedAlgorithm +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec, rsa +from cryptography.hazmat.primitives.serialization import pkcs12 + +from ._internal.jose import import_jwk_key_pair +from .exceptions import OAuth2ConfigError +from .models import DPoPKey, KeyPair, PrivateKey + +type KeySource = str | PathLike[str] | bytes +type JwkSource = KeySource | Mapping[str, str | list[str]] + +_MINIMUM_RSA_KEY_SIZE = 2048 +_MINIMUM_EC_KEY_SIZE = 224 + + +def _read_source(source: KeySource) -> bytes: + if isinstance(source, bytes): + return source + return Path(source).read_bytes() + + +def _password_bytes(password: str | None) -> bytes | None: + return password.encode("utf-8") if password is not None else None + + +def validate_key(key: object) -> None: + """Validate an RSA or EC key against the supported FAPI minimums.""" + + if isinstance(key, rsa.RSAPrivateKey | rsa.RSAPublicKey): + if key.key_size < _MINIMUM_RSA_KEY_SIZE: + raise OAuth2ConfigError( + f"RSA keys must have a minimum length of {_MINIMUM_RSA_KEY_SIZE} bits, " + f"but key length was: {key.key_size}" + ) + return + + if isinstance(key, ec.EllipticCurvePrivateKey | ec.EllipticCurvePublicKey): + if key.key_size < _MINIMUM_EC_KEY_SIZE: + raise OAuth2ConfigError( + f"Elliptic curve keys must have a minimum length of {_MINIMUM_EC_KEY_SIZE} bits, " + f"but key length was: {key.key_size}" + ) + return + + algorithm = type(key).__name__ + raise OAuth2ConfigError(f"Key algorithm must be RSA or EC, but was: {algorithm}") + + +def validate_private_key(key: object, *, context: str = "Key") -> PrivateKey: + """Validate that a signing key is a supported private key.""" + + validate_key(key) + if isinstance(key, rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey): + return key + raise OAuth2ConfigError(f"{context} must be an RSA or EC private key") + + +def validate_dpop_key(dpop_key: object) -> None: + """Validate the current DPoP key returned by a provider.""" + + if not isinstance(dpop_key, DPoPKey) or dpop_key.key_pair is None: + raise OAuth2ConfigError("DPoP key provider must return a valid DPoP key") + if not isinstance(dpop_key.key_id, str) or not dpop_key.key_id.strip(): + raise OAuth2ConfigError("DPoP key provider must return a valid DPoP key ID") + validate_key(dpop_key.key_pair.private_key) + validate_key(dpop_key.key_pair.public_key) + + +def load_private_key(source: KeySource, password: str | None = None) -> PrivateKey: + """Load and validate an RSA or EC private key from PEM or DER data.""" + + key_data = _read_source(source) + password_bytes = _password_bytes(password) + + try: + if key_data.lstrip().startswith(b"-----BEGIN"): + loaded_key = serialization.load_pem_private_key(key_data, password=password_bytes) + else: + loaded_key = serialization.load_der_private_key(key_data, password=password_bytes) + except (TypeError, ValueError, UnsupportedAlgorithm) as error: + raise OAuth2ConfigError("Unable to load private key") from error + + return validate_private_key(loaded_key) + + +def load_pkcs12_private_key(source: KeySource, password: str | None = None) -> PrivateKey: + """Load and validate the private key contained in PKCS#12 data.""" + + key_data = _read_source(source) + password_bytes = _password_bytes(password) + + try: + private_key, _, _ = pkcs12.load_key_and_certificates(key_data, password_bytes) + except (TypeError, ValueError, UnsupportedAlgorithm) as error: + raise OAuth2ConfigError("Unable to load PKCS#12 private key") from error + + if private_key is None: + raise OAuth2ConfigError("PKCS#12 data does not contain a private key") + + return validate_private_key(private_key) + + +def load_jwk_key_pair(source: JwkSource) -> KeyPair: + """Parse an RFC 7517 RSA or EC private JWK and construct its key pair.""" + + try: + value = dict(source) if isinstance(source, Mapping) else json.loads(_read_source(source)) + if not isinstance(value, dict): + raise TypeError("JWK must be a JSON object") + return import_jwk_key_pair(value) + except OAuth2ConfigError: + raise + except (json.JSONDecodeError, UnicodeDecodeError) as error: + raise OAuth2ConfigError("Unable to parse JWK JSON") from error + except (TypeError, ValueError) as error: + raise OAuth2ConfigError("Unable to load JWK key pair") from error diff --git a/src/mastercard_oauth2_client/models.py b/src/mastercard_oauth2_client/models.py new file mode 100644 index 0000000..c6fd2b2 --- /dev/null +++ b/src/mastercard_oauth2_client/models.py @@ -0,0 +1,62 @@ +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime + +from cryptography.hazmat.primitives.asymmetric import ec, rsa + +type PrivateKey = rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey +type PublicKey = rsa.RSAPublicKey | ec.EllipticCurvePublicKey + + +@dataclass(frozen=True, slots=True) +class KeyPair: + """An asymmetric private/public key pair used for DPoP signing.""" + + private_key: PrivateKey + public_key: PublicKey + + +@dataclass(frozen=True, slots=True) +class DPoPKey: + """A DPoP signing key pair and its stable key identifier.""" + + key_id: str + key_pair: KeyPair + + +@dataclass(frozen=True, slots=True) +class AccessToken: + """A cached access token and the metadata required for safe reuse.""" + + client_id: str + token_value: str + scopes: frozenset[str] + expires_at: datetime + jkt: str | None = None + + +@dataclass(frozen=True, slots=True) +class AccessTokenFilter: + """Criteria used together to find a reusable access token.""" + + scopes: frozenset[str] + jkt: str | None = None + + +@dataclass(frozen=True, slots=True) +class HttpRequest: + """HTTP-client-neutral token-endpoint request.""" + + method: str + url: str + headers: Mapping[str, str] + body: bytes | str | None = None + + +@dataclass(frozen=True, slots=True) +class HttpResponse: + """HTTP-client-neutral token-endpoint response snapshot.""" + + status: int + headers: Mapping[str, str] + body: bytes | str | None = None diff --git a/src/mastercard_oauth2_client/protocols.py b/src/mastercard_oauth2_client/protocols.py new file mode 100644 index 0000000..65f9de8 --- /dev/null +++ b/src/mastercard_oauth2_client/protocols.py @@ -0,0 +1,116 @@ +from collections.abc import Mapping +from collections.abc import Set as AbstractSet +from typing import Protocol + +from ._internal._scopes import normalize_scopes +from ._internal.jose import jwk_thumbprint +from .models import AccessToken, AccessTokenFilter, DPoPKey, HttpRequest, KeyPair + + +class ScopeResolver(Protocol): + """Determines the OAuth2 scopes required for a resource request.""" + + def resolve(self, method: str, url: str) -> frozenset[str]: ... + + def all_scopes(self) -> frozenset[str]: ... + + +class TokenStore(Protocol): + """Stores reusable access tokens for one OAuth2 client configuration.""" + + def put(self, access_token: AccessToken) -> None: ... + + def get(self, token_filter: AccessTokenFilter) -> AccessToken | None: ... + + +class DPoPKeyProvider(Protocol): + """Supplies the current DPoP key and resolves keys by identifier.""" + + def get_current_key(self) -> DPoPKey: ... + + def get_key(self, key_id: str) -> DPoPKey: ... + + +class SyncHttpAdapter[RequestT, ResponseT](Protocol): + """Bridge OAuth2 orchestration to native resource request and response types. + + Response inspection must preserve any final response returned to the caller. + The handler closes only token responses it consumes and challenged resource + responses it replaces with a retry. + """ + + def request_method(self, request: RequestT) -> str: ... + + def request_url(self, request: RequestT) -> str: ... + + def request_headers(self, request: RequestT) -> Mapping[str, str]: ... + + def send_token_request(self, resource_request: RequestT, request: HttpRequest) -> ResponseT: ... + + def send_resource_request(self, request: RequestT, headers: Mapping[str, str]) -> ResponseT: ... + + def response_status(self, response: ResponseT) -> int: ... + + def response_headers(self, response: ResponseT) -> Mapping[str, str]: ... + + def response_body(self, response: ResponseT) -> bytes | str | None: ... + + def close_response(self, response: ResponseT) -> None: ... + + +class AsyncHttpAdapter[RequestT, ResponseT](Protocol): + """Bridge OAuth2 orchestration to asynchronous native HTTP client types. + + Response inspection must preserve any final response returned to the caller. + The handler closes only token responses it consumes and challenged resource + responses it replaces with a retry. + """ + + def request_method(self, request: RequestT) -> str: ... + + def request_url(self, request: RequestT) -> str: ... + + def request_headers(self, request: RequestT) -> Mapping[str, str]: ... + + async def send_token_request(self, resource_request: RequestT, request: HttpRequest) -> ResponseT: ... + + async def send_resource_request(self, request: RequestT, headers: Mapping[str, str]) -> ResponseT: ... + + def response_status(self, response: ResponseT) -> int: ... + + def response_headers(self, response: ResponseT) -> Mapping[str, str]: ... + + async def response_body(self, response: ResponseT) -> bytes | str | None: ... + + async def close_response(self, response: ResponseT) -> None: ... + + +class StaticScopeResolver: + """Returns one fixed normalized scope set for every resource request.""" + + def __init__(self, scopes: AbstractSet[str]) -> None: + self._scopes = normalize_scopes(scopes) + + def resolve(self, method: str, url: str) -> frozenset[str]: + # Method and URL are intentionally ignored because this resolver is static. + return self._scopes + + def all_scopes(self) -> frozenset[str]: + return self._scopes + + +class StaticDPoPKeyProvider: + """Returns one fixed DPoP key when key rotation is not required.""" + + def __init__(self, key_pair: KeyPair) -> None: + self._dpop_key = DPoPKey( + key_id=jwk_thumbprint(key_pair.public_key), + key_pair=key_pair, + ) + + def get_current_key(self) -> DPoPKey: + return self._dpop_key + + def get_key(self, key_id: str) -> DPoPKey: + # The key ID is intentionally ignored because this provider has one key. + return self._dpop_key diff --git a/src/mastercard_oauth2_client/py.typed b/src/mastercard_oauth2_client/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/mastercard_oauth2_client/store.py b/src/mastercard_oauth2_client/store.py new file mode 100644 index 0000000..2b5f376 --- /dev/null +++ b/src/mastercard_oauth2_client/store.py @@ -0,0 +1,63 @@ +from datetime import timedelta +from threading import RLock + +from ._internal._clock import NowProvider +from ._internal._clock import utc_now as _utc_now +from .models import AccessToken, AccessTokenFilter + +type TokenCacheKey = tuple[str | None, tuple[str, ...]] + + +class InMemoryTokenStore: + """Thread-safe token cache for one OAuth2 client configuration. + + A DPoP-bound token can be retrieved by scopes alone or by scopes and its JWK + thumbprint. Use a separate instance for each configured OAuth2 client. + """ + + EARLY_EXPIRATION_THRESHOLD = timedelta(seconds=60) + + def __init__(self, now_provider: NowProvider = _utc_now) -> None: + self._now = now_provider + self._tokens: dict[TokenCacheKey, AccessToken] = {} + # Keep each lookup or update atomic. + self._lock = RLock() + + def put(self, access_token: AccessToken) -> None: + with self._lock: + self._remove_expired_tokens() + + scope_key = self._create_key(jkt=None, scopes=access_token.scopes) + self._tokens[scope_key] = access_token + + if access_token.jkt is not None: + dpop_bound_key = self._create_key(jkt=access_token.jkt, scopes=access_token.scopes) + self._tokens[dpop_bound_key] = access_token + + def get(self, token_filter: AccessTokenFilter) -> AccessToken | None: + key = self._create_key(token_filter.jkt, token_filter.scopes) + with self._lock: + access_token = self._tokens.get(key) + if access_token is None: + return None + + earliest_usable_expiry = self._now() + self.EARLY_EXPIRATION_THRESHOLD + if access_token.expires_at < earliest_usable_expiry: + self._tokens.pop(key, None) + return None + + return access_token + + @staticmethod + def _create_key(jkt: str | None, scopes: frozenset[str]) -> TokenCacheKey: + """Return a stable key independent of the caller's scope ordering.""" + + return jkt, tuple(sorted(scopes)) + + def _remove_expired_tokens(self) -> None: + """Discard expired entries during writes to prevent unbounded growth.""" + + now = self._now() + expired_keys = [key for key, token in self._tokens.items() if token.expires_at < now] + for key in expired_keys: + self._tokens.pop(key, None) diff --git a/src/mastercard_oauth2_client/token.py b/src/mastercard_oauth2_client/token.py new file mode 100644 index 0000000..21a8c01 --- /dev/null +++ b/src/mastercard_oauth2_client/token.py @@ -0,0 +1,160 @@ +import json +import math +from collections.abc import Mapping +from collections.abc import Set as AbstractSet +from datetime import timedelta +from urllib.parse import urlencode + +from ._internal._clock import NowProvider as _NowProvider +from ._internal._clock import utc_now as _utc_now +from ._internal._scopes import normalize_scopes as _normalize_scopes +from ._internal.http import is_success_status as _is_success_status +from ._internal.jose import jwk_thumbprint +from .config import OAuth2Config +from .dpop import create_client_assertion, create_token_dpop_proof +from .exceptions import OAuth2Error +from .keys import validate_dpop_key +from .models import AccessToken, HttpRequest, HttpResponse + +_CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + + +def _build_token_request_body(config: OAuth2Config, scopes: AbstractSet[str]) -> str: + """Build the RFC 6749 client-credentials form body.""" + + form_fields = [ + ("grant_type", "client_credentials"), + ("client_id", config.client_id), + ] + normalized_scopes = _normalize_scopes(scopes) + if normalized_scopes: + form_fields.append(("scope", " ".join(sorted(normalized_scopes)))) + form_fields.extend( + [ + ("client_assertion_type", _CLIENT_ASSERTION_TYPE), + ("client_assertion", create_client_assertion(config)), + ] + ) + return urlencode(form_fields) + + +def _build_token_request_headers( + config: OAuth2Config, + dpop_key_id: str, + nonce: str | None, +) -> dict[str, str]: + """Build token-endpoint headers including a fresh DPoP proof.""" + + return { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "DPoP": create_token_dpop_proof(config, dpop_key_id, nonce), + "User-Agent": config.user_agent, + } + + +def build_token_request( + config: OAuth2Config, + scopes: AbstractSet[str], + dpop_key_id: str, + nonce: str | None = None, +) -> HttpRequest: + """Build a neutral RFC 6749 client-credentials request using DPoP.""" + + return HttpRequest( + method="POST", + url=config.token_endpoint, + headers=_build_token_request_headers(config, dpop_key_id, nonce), + body=_build_token_request_body(config, scopes), + ) + + +def _read_token_response_json(body: bytes | str | None) -> Mapping[str, object]: + if body is None or not body.strip(): + raise OAuth2Error("Empty access token response") + try: + value = json.loads(body) + except (json.JSONDecodeError, UnicodeDecodeError) as error: + raise OAuth2Error("Failed to parse JSON access token response") from error + if not isinstance(value, dict): + raise OAuth2Error("Failed to parse JSON access token response") + return value + + +def _read_required_string(value: Mapping[str, object], field: str) -> str: + field_value = value.get(field) + if not isinstance(field_value, str) or not field_value: + raise OAuth2Error(f"Missing value in access token response: {field}") + return field_value + + +def _read_expires_in(value: Mapping[str, object]) -> float: + if "expires_in" not in value: + raise OAuth2Error("Missing value in access token response: expires_in") + expires_in = value["expires_in"] + if isinstance(expires_in, bool) or not isinstance(expires_in, int | float): + raise OAuth2Error( + "FAPI 2.0 requires valid expires_in field in token response for proper token lifetime management" + ) + if not math.isfinite(expires_in) or expires_in <= 0: + raise OAuth2Error( + "FAPI 2.0 requires valid expires_in field in token response for proper token lifetime management" + ) + return float(expires_in) + + +def _read_granted_scopes(value: Mapping[str, object], requested_scopes: AbstractSet[str]) -> frozenset[str]: + """Return granted scopes, treating an omitted RFC 6749 scope as unchanged.""" + + scope_value = value.get("scope") + if scope_value is None: + return _normalize_scopes(requested_scopes) + if not isinstance(scope_value, str): + raise OAuth2Error("Token response scope must be a string") + return frozenset(scope_value.split()) + + +def parse_token_response( + config: OAuth2Config, + response: HttpResponse, + requested_scopes: AbstractSet[str], + dpop_key_id: str, +) -> AccessToken: + """Parse a successful RFC 6749 response into a cache-ready DPoP token.""" + + return _parse_token_response( + config, + response, + requested_scopes, + dpop_key_id, + now_provider=_utc_now, + ) + + +def _parse_token_response( + config: OAuth2Config, + response: HttpResponse, + requested_scopes: AbstractSet[str], + dpop_key_id: str, + *, + now_provider: _NowProvider, +) -> AccessToken: + if not _is_success_status(response.status): + raise OAuth2Error(f"Token request failed with HTTP {response.status}") + + value = _read_token_response_json(response.body) + access_token = _read_required_string(value, "access_token") + token_type = _read_required_string(value, "token_type") + if token_type.casefold() != "dpop": + raise OAuth2Error(f"Expected DPoP token type but received: {token_type}") + expires_in = _read_expires_in(value) + + dpop_key = config.dpop_key_provider.get_key(dpop_key_id) + validate_dpop_key(dpop_key) + return AccessToken( + client_id=config.client_id, + token_value=access_token, + scopes=_read_granted_scopes(value, requested_scopes), + expires_at=now_provider() + timedelta(seconds=expires_in), + jkt=jwk_thumbprint(dpop_key.key_pair.public_key), + ) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..ac63e65 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,27 @@ +from collections.abc import Iterator + +import pytest + +from tests.integration.support.environment import OAuthTestEnvironment + + +@pytest.fixture(scope="session") +def oauth_servers(tmp_path_factory: pytest.TempPathFactory) -> Iterator[OAuthTestEnvironment]: + """Start one TLS server pair for the session to keep integration tests fast.""" + + environment = OAuthTestEnvironment.start(tmp_path_factory.mktemp("oauth-https")) + try: + yield environment + finally: + environment.close() + + +@pytest.fixture +def oauth_environment(oauth_servers: OAuthTestEnvironment) -> Iterator[OAuthTestEnvironment]: + """Give each test clean request logs, resources, bindings and error modes.""" + + oauth_servers.reset() + try: + yield oauth_servers + finally: + oauth_servers.reset() diff --git a/tests/integration/support/__init__.py b/tests/integration/support/__init__.py new file mode 100644 index 0000000..0480f98 --- /dev/null +++ b/tests/integration/support/__init__.py @@ -0,0 +1 @@ +"""Reusable support for local HTTPS integration tests.""" diff --git a/tests/integration/support/assertions.py b/tests/integration/support/assertions.py new file mode 100644 index 0000000..5ed7bbd --- /dev/null +++ b/tests/integration/support/assertions.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa, utils + +from mastercard_oauth2_client.models import PublicKey + + +@dataclass(frozen=True, slots=True) +class VerifiedJwt: + header: dict[str, object] + claims: dict[str, object] + + +class JwtValidationError(ValueError): + pass + + +def verify_jwt(compact_jwt: str, public_key: PublicKey | None = None) -> VerifiedJwt: + """Verify compact JOSE independently of the library's signing implementation.""" + + try: + encoded_header, encoded_claims, encoded_signature = compact_jwt.split(".") + header = _json_object(_decode(encoded_header)) + claims = _json_object(_decode(encoded_claims)) + verification_key = public_key or _public_key_from_jwk(header.get("jwk")) + signature = _decode(encoded_signature) + signing_input = f"{encoded_header}.{encoded_claims}".encode("ascii") + _verify_signature(verification_key, header.get("alg"), signing_input, signature) + return VerifiedJwt(header=header, claims=claims) + except JwtValidationError: + raise + except (InvalidSignature, TypeError, UnicodeError, ValueError) as error: + raise JwtValidationError("JWT signature or encoding is invalid") from error + + +def validate_time_claims(claims: dict[str, object], *, now: int | None = None) -> None: + """Reject stale, future or unexpectedly long-lived test assertions and proofs.""" + + current = int(datetime.now(UTC).timestamp()) if now is None else now + issued_at = _integer_claim(claims, "iat") + expires_at = _integer_claim(claims, "exp") + if issued_at > current + 60 or expires_at <= current or expires_at - issued_at > 180: + raise JwtValidationError("JWT time claims are invalid") + if "nbf" in claims and _integer_claim(claims, "nbf") > current + 60: + raise JwtValidationError("JWT nbf claim is invalid") + + +def access_token_hash(access_token: str) -> str: + return _encode(hashlib.sha256(access_token.encode("ascii")).digest()) + + +def jwk_thumbprint(jwk: object) -> str: + if not isinstance(jwk, dict): + raise JwtValidationError("DPoP JWK is missing") + key_type = jwk.get("kty") + members = ("e", "kty", "n") if key_type == "RSA" else ("crv", "kty", "x", "y") + canonical = {name: _string_member(jwk, name) for name in members} + return _encode(hashlib.sha256(json.dumps(canonical, separators=(",", ":"), sort_keys=True).encode("ascii")).digest()) + + +def _verify_signature(public_key: PublicKey, algorithm: object, data: bytes, signature: bytes) -> None: + if algorithm == "PS256" and isinstance(public_key, rsa.RSAPublicKey): + public_key.verify( + signature, + data, + padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=32), + hashes.SHA256(), + ) + return + if algorithm == "ES256" and isinstance(public_key, ec.EllipticCurvePublicKey): + component_size = (public_key.key_size + 7) // 8 + if len(signature) != component_size * 2: + raise JwtValidationError("ES256 signature length is invalid") + r = int.from_bytes(signature[:component_size], "big") + s = int.from_bytes(signature[component_size:], "big") + public_key.verify(utils.encode_dss_signature(r, s), data, ec.ECDSA(hashes.SHA256())) + return + raise JwtValidationError("JWT algorithm does not match its key") + + +def _public_key_from_jwk(value: object) -> PublicKey: + if not isinstance(value, dict): + raise JwtValidationError("DPoP JWK is missing") + if value.get("kty") == "RSA": + return rsa.RSAPublicNumbers( + e=_decode_uint(_string_member(value, "e")), + n=_decode_uint(_string_member(value, "n")), + ).public_key() + if value.get("kty") == "EC" and value.get("crv") == "P-256": + return ec.EllipticCurvePublicNumbers( + x=_decode_uint(_string_member(value, "x")), + y=_decode_uint(_string_member(value, "y")), + curve=ec.SECP256R1(), + ).public_key() + raise JwtValidationError("Unsupported DPoP JWK") + + +def _json_object(value: bytes) -> dict[str, Any]: + decoded = json.loads(value) + if not isinstance(decoded, dict): + raise JwtValidationError("JWT section must be a JSON object") + return decoded + + +def _integer_claim(claims: dict[str, object], name: str) -> int: + value = claims.get(name) + if isinstance(value, bool) or not isinstance(value, int): + raise JwtValidationError(f"JWT {name} claim is invalid") + return value + + +def _string_member(value: dict[object, object], name: str) -> str: + member = value.get(name) + if not isinstance(member, str) or not member: + raise JwtValidationError(f"JWK {name} member is invalid") + return member + + +def _decode_uint(value: str) -> int: + return int.from_bytes(_decode(value), "big") + + +def _decode(value: str) -> bytes: + return base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True) + + +def _encode(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") diff --git a/tests/integration/support/authorization_server.py b/tests/integration/support/authorization_server.py new file mode 100644 index 0000000..d6cab3d --- /dev/null +++ b/tests/integration/support/authorization_server.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import json +import ssl +from collections.abc import Mapping +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread +from typing import Self +from urllib.parse import parse_qs + +from mastercard_oauth2_client._internal.jose import algorithm_for_key + +from .assertions import JwtValidationError, jwk_thumbprint, validate_time_claims, verify_jwt +from .models import AuthorizationState, RecordedRequest +from .tls import TlsFiles + +_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" +TOKEN_PATH = "/oauth/token" + + +class _AuthorizationHttpServer(ThreadingHTTPServer): + daemon_threads = True + state: AuthorizationState + + +class _AuthorizationHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_POST(self) -> None: + server = self.server + if not isinstance(server, _AuthorizationHttpServer): + raise TypeError("Unexpected authorization server") + request = _record_request(self) + server.state.requests.append(request) + if server.state.initial_token_request_barrier is not None and len(server.state.requests) <= 2: + server.state.initial_token_request_barrier.wait() + if self.path != TOKEN_PATH: + self._send_json(404, {"error": "not_found"}) + return + try: + self._validate_request(server.state, request) + except JwtValidationError as error: + self._send_json(400, {"error": "invalid_dpop_proof", "error_description": str(error)}) + return + except ValueError as error: + self._send_json(400, {"error": "invalid_client", "error_description": str(error)}) + return + + dpop = verify_jwt(_required_header(request, "dpop")) + provided_nonce = dpop.claims.get("nonce") + if server.state.always_challenge_nonce or provided_nonce != server.state.nonce: + self._send_json( + 400, + {"error": "use_dpop_nonce"}, + {"DPoP-Nonce": server.state.nonce}, + ) + return + + server.state.binding.jkt = jwk_thumbprint(dpop.header.get("jwk")) + self._send_json( + 200, + { + "access_token": server.state.access_token, + "token_type": "DPoP", + "expires_in": 900, + "scope": " ".join(sorted(server.state.scopes)), + }, + {"DPoP-Nonce": server.state.nonce}, + ) + + def log_message(self, format: str, *args: object) -> None: + return + + def _validate_request(self, state: AuthorizationState, request: RecordedRequest) -> None: + _require_single_headers(request, ("accept", "content-type", "dpop", "user-agent")) + if request.header("accept") != "application/json": + raise ValueError("Accept header is invalid") + if request.header("content-type") != "application/x-www-form-urlencoded": + raise ValueError("Content-Type header is invalid") + form = parse_qs(request.body.decode("utf-8"), keep_blank_values=True) + _require_form_value(form, "grant_type", "client_credentials") + _require_form_value(form, "client_id", state.client_id) + _require_form_value(form, "client_assertion_type", _ASSERTION_TYPE) + if set(_single_form_value(form, "scope").split()) != state.scopes: + raise ValueError("Requested scopes are invalid") + if state.reject_client_assertion: + raise ValueError("Client assertion was rejected") + + try: + assertion = verify_jwt(_single_form_value(form, "client_assertion"), state.client_public_key) + except JwtValidationError as error: + raise ValueError("Client assertion signature is invalid") from error + if assertion.header.get("typ") != "JWT" or assertion.header.get("kid") != state.key_id: + raise ValueError("Client assertion header is invalid") + if assertion.header.get("alg") != algorithm_for_key(state.client_public_key): + raise ValueError("Client assertion algorithm is invalid") + for claim in ("iss", "sub"): + if assertion.claims.get(claim) != state.client_id: + raise ValueError(f"Client assertion {claim} is invalid") + if assertion.claims.get("aud") != _request_origin(request): + raise ValueError("Client assertion audience is invalid") + _require_string_claim(assertion.claims, "jti") + validate_time_claims(assertion.claims) + + dpop = verify_jwt(_required_header(request, "dpop")) + if dpop.header.get("typ") != "dpop+jwt": + raise JwtValidationError("DPoP typ is invalid") + dpop_jkt = jwk_thumbprint(dpop.header.get("jwk")) + if dpop.header.get("kid") != dpop_jkt: + raise JwtValidationError("DPoP key ID is invalid") + if dpop.claims.get("htm") != "POST" or dpop.claims.get("htu") != f"{_request_origin(request)}{TOKEN_PATH}": + raise JwtValidationError("DPoP request binding is invalid") + if "ath" in dpop.claims: + raise JwtValidationError("Token DPoP must not contain ath") + _require_string_claim(dpop.claims, "jti") + validate_time_claims(dpop.claims) + + def _send_json(self, status: int, value: object, headers: Mapping[str, str] | None = None) -> None: + body = json.dumps(value, separators=(",", ":")).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + for name, header_value in (headers or {}).items(): + self.send_header(name, header_value) + self.end_headers() + self.wfile.write(body) + + +class FakeAuthorizationServer: + """Local HTTPS token endpoint with configurable deterministic test state.""" + + def __init__(self, state: AuthorizationState, tls_files: TlsFiles) -> None: + self.state = state + self._server = _AuthorizationHttpServer(("127.0.0.1", 0), _AuthorizationHandler) + self._server.state = state + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(tls_files.certificate, tls_files.private_key) + self._server.socket = context.wrap_socket(self._server.socket, server_side=True) + self._thread = Thread(target=self._server.serve_forever, daemon=True) + + @property + def base_url(self) -> str: + return f"https://127.0.0.1:{self._server.server_port}" + + def __enter__(self) -> Self: + self._thread.start() + return self + + def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None: + self._server.shutdown() + self._server.server_close() + self._thread.join() + + +def _record_request(handler: BaseHTTPRequestHandler) -> RecordedRequest: + content_length = int(handler.headers.get("Content-Length", "0")) + headers = { + name.casefold(): tuple(handler.headers.get_all(name, failobj=[])) + for name in dict(handler.headers) + } + return RecordedRequest( + method=handler.command, + path=handler.path, + headers=headers, + body=handler.rfile.read(content_length), + client_port=handler.client_address[1], + ) + + +def _required_header(request: RecordedRequest, name: str) -> str: + value = request.header(name) + if value is None: + raise ValueError(f"Missing or duplicate header: {name}") + return value + + +def _require_single_headers(request: RecordedRequest, names: tuple[str, ...]) -> None: + for name in names: + _required_header(request, name) + + +def _single_form_value(form: dict[str, list[str]], name: str) -> str: + values = form.get(name, []) + if len(values) != 1 or not values[0]: + raise ValueError(f"Form field is missing or duplicated: {name}") + return values[0] + + +def _require_form_value(form: dict[str, list[str]], name: str, expected: str) -> None: + if _single_form_value(form, name) != expected: + raise ValueError(f"Form field is invalid: {name}") + + +def _require_string_claim(claims: dict[str, object], name: str) -> str: + value = claims.get(name) + if not isinstance(value, str) or not value: + raise JwtValidationError(f"JWT {name} claim is invalid") + return value + + +def _request_origin(request: RecordedRequest) -> str: + host = _required_header(request, "host") + return f"https://{host}" diff --git a/tests/integration/support/environment.py b/tests/integration/support/environment.py new file mode 100644 index 0000000..6bc47c6 --- /dev/null +++ b/tests/integration/support/environment.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from contextlib import ExitStack +from dataclasses import dataclass +from pathlib import Path +from typing import Self + +from cryptography.hazmat.primitives.asymmetric import ec, rsa + +from mastercard_oauth2_client import ( + InMemoryTokenStore, + KeyPair, + OAuth2Config, + StaticDPoPKeyProvider, + StaticScopeResolver, +) +from mastercard_oauth2_client.models import PrivateKey +from mastercard_oauth2_client.protocols import TokenStore + +from .authorization_server import TOKEN_PATH, FakeAuthorizationServer +from .models import AuthorizationState, DPoPAlgorithm, ResourceState, TokenBinding +from .resource_server import FakeResourceServer +from .tls import TlsFiles, create_tls_files + + +@dataclass(slots=True) +class OAuthTestEnvironment: + """Own the shared HTTPS servers and create isolated OAuth2 configurations.""" + + tls_files: TlsFiles + client_key: PrivateKey + authorization: FakeAuthorizationServer + resource: FakeResourceServer + _stack: ExitStack + + @classmethod + def start(cls, directory: Path) -> Self: + """Start authorization and resource servers sharing one token binding.""" + + tls_files = create_tls_files(directory) + client_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + binding = TokenBinding() + authorization_state = AuthorizationState( + client_id="integration-client", + key_id="integration-key", + client_public_key=client_key.public_key(), + scopes=frozenset({"resources:read", "resources:write"}), + binding=binding, + ) + resource_state = ResourceState(binding=binding) + stack = ExitStack() + authorization = stack.enter_context(FakeAuthorizationServer(authorization_state, tls_files)) + resource = stack.enter_context(FakeResourceServer(resource_state, tls_files)) + return cls( + tls_files=tls_files, + client_key=client_key, + authorization=authorization, + resource=resource, + _stack=stack, + ) + + def reset(self) -> None: + """Clear all scenario state before and after each integration test.""" + + self.authorization.state.reset() + self.resource.state.reset() + + def config(self, algorithm: DPoPAlgorithm, *, token_store: TokenStore | None = None) -> OAuth2Config: + """Create a fresh client config with the requested DPoP key algorithm.""" + + dpop_private_key: PrivateKey + if algorithm == "ec": + dpop_private_key = ec.generate_private_key(ec.SECP256R1()) + else: + dpop_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + selected_store = token_store if token_store is not None else InMemoryTokenStore() + return OAuth2Config( + client_id=self.authorization.state.client_id, + token_endpoint=f"{self.authorization.base_url}{TOKEN_PATH}", + issuer=self.authorization.base_url, + client_key=self.client_key, + key_id=self.authorization.state.key_id, + scope_resolver=StaticScopeResolver(self.authorization.state.scopes), + dpop_key_provider=StaticDPoPKeyProvider( + KeyPair(private_key=dpop_private_key, public_key=dpop_private_key.public_key()) + ), + token_store=selected_store, + ) + + def close(self) -> None: + self._stack.close() diff --git a/tests/integration/support/lifecycle.py b/tests/integration/support/lifecycle.py new file mode 100644 index 0000000..90bf0c5 --- /dev/null +++ b/tests/integration/support/lifecycle.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Protocol + + +class LifecycleClient(Protocol): + """Common fake-resource operations every synchronous test client exposes.""" + + def create_resource(self, name: str) -> str: ... + + def get_resource(self, resource_id: str) -> str: ... + + def delete_resource(self, resource_id: str) -> None: ... + + +class AsyncLifecycleClient(Protocol): + """Common fake-resource operations every asynchronous test client exposes.""" + + async def create_resource(self, name: str) -> str: ... + + async def get_resource(self, resource_id: str) -> str: ... + + async def delete_resource(self, resource_id: str) -> None: ... + + +def run_resource_lifecycle(client: LifecycleClient, *, name: str = "integration-resource") -> str: + """Create and fetch one resource, always deleting it before returning.""" + + resource_id = client.create_resource(name) + try: + fetched_id = client.get_resource(resource_id) + if fetched_id != resource_id: + raise AssertionError("Fetched resource ID does not match created resource ID") + return resource_id + finally: + client.delete_resource(resource_id) + + +async def run_async_resource_lifecycle( + client: AsyncLifecycleClient, + *, + name: str = "integration-resource", +) -> str: + """Create and fetch one resource asynchronously, always deleting it.""" + + resource_id = await client.create_resource(name) + try: + fetched_id = await client.get_resource(resource_id) + if fetched_id != resource_id: + raise AssertionError("Fetched resource ID does not match created resource ID") + return resource_id + finally: + await client.delete_resource(resource_id) + + +def require_resource_id(value: object) -> str: + """Return a valid resource ID from a decoded JSON object.""" + + if not isinstance(value, dict): + raise TypeError("Response body is not a JSON object") + resource_id = value.get("id") + if not isinstance(resource_id, str) or not resource_id: + raise AssertionError("Resource response has no valid ID") + return resource_id diff --git a/tests/integration/support/lifecycle_clients/__init__.py b/tests/integration/support/lifecycle_clients/__init__.py new file mode 100644 index 0000000..4036b3c --- /dev/null +++ b/tests/integration/support/lifecycle_clients/__init__.py @@ -0,0 +1 @@ +"""Client-specific adapters for shared integration lifecycle scenarios.""" diff --git a/tests/integration/support/lifecycle_clients/aiohttp.py b/tests/integration/support/lifecycle_clients/aiohttp.py new file mode 100644 index 0000000..d98a802 --- /dev/null +++ b/tests/integration/support/lifecycle_clients/aiohttp.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import aiohttp + +from ..lifecycle import require_resource_id + + +@dataclass(slots=True) +class AiohttpLifecycleClient: + """Run the shared lifecycle through a native aiohttp client session.""" + + client: aiohttp.ClientSession + base_url: str + + async def create_resource(self, name: str) -> str: + response = await self.client.post(f"{self.base_url}/api/resources", json={"name": name}) + response.raise_for_status() + return require_resource_id(await response.json()) + + async def get_resource(self, resource_id: str) -> str: + response = await self.client.get( + f"{self.base_url}/api/resources/{resource_id}", + headers={"Accept": "application/json"}, + ) + response.raise_for_status() + return require_resource_id(await response.json()) + + async def delete_resource(self, resource_id: str) -> None: + response = await self.client.delete(f"{self.base_url}/api/resources/{resource_id}") + response.raise_for_status() diff --git a/tests/integration/support/lifecycle_clients/httpx.py b/tests/integration/support/lifecycle_clients/httpx.py new file mode 100644 index 0000000..3c75b19 --- /dev/null +++ b/tests/integration/support/lifecycle_clients/httpx.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import httpx + +from ..lifecycle import require_resource_id + + +@dataclass(slots=True) +class HttpxLifecycleClient: + """Run the shared lifecycle through a native synchronous HTTPX client.""" + + client: httpx.Client + base_url: str + + def create_resource(self, name: str) -> str: + response = self.client.post(f"{self.base_url}/api/resources", json={"name": name}) + response.raise_for_status() + return require_resource_id(response.json()) + + def get_resource(self, resource_id: str) -> str: + response = self.client.get( + f"{self.base_url}/api/resources/{resource_id}", + headers={"Accept": "application/json"}, + ) + response.raise_for_status() + return require_resource_id(response.json()) + + def delete_resource(self, resource_id: str) -> None: + response = self.client.delete(f"{self.base_url}/api/resources/{resource_id}") + response.raise_for_status() + + +@dataclass(slots=True) +class AsyncHttpxLifecycleClient: + """Run the shared lifecycle through a native asynchronous HTTPX client.""" + + client: httpx.AsyncClient + base_url: str + + async def create_resource(self, name: str) -> str: + response = await self.client.post(f"{self.base_url}/api/resources", json={"name": name}) + response.raise_for_status() + return require_resource_id(response.json()) + + async def get_resource(self, resource_id: str) -> str: + response = await self.client.get( + f"{self.base_url}/api/resources/{resource_id}", + headers={"Accept": "application/json"}, + ) + response.raise_for_status() + return require_resource_id(response.json()) + + async def delete_resource(self, resource_id: str) -> None: + response = await self.client.delete(f"{self.base_url}/api/resources/{resource_id}") + response.raise_for_status() diff --git a/tests/integration/support/lifecycle_clients/openapi_asyncio.py b/tests/integration/support/lifecycle_clients/openapi_asyncio.py new file mode 100644 index 0000000..3618374 --- /dev/null +++ b/tests/integration/support/lifecycle_clients/openapi_asyncio.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from generated_asyncio_test_client import ( # type: ignore[import-not-found] + ApiClient, + Configuration, + Resource, + ResourcesApi, +) + +from mastercard_oauth2_client import OAuth2Config +from mastercard_oauth2_client.integrations.openapi.asyncio import add_oauth2_layer + + +@dataclass(slots=True) +class OpenApiAsyncioLifecycleClient: + """Run the shared lifecycle through a generated Python/asyncio client.""" + + api: ResourcesApi + api_client: ApiClient + + @classmethod + def create(cls, base_url: str, ca_file: str, config: OAuth2Config) -> OpenApiAsyncioLifecycleClient: + generated_config = Configuration(host=base_url) + generated_config.ssl_ca_cert = ca_file + generated_config.proxy = None + api_client = ApiClient(generated_config) + add_oauth2_layer(api_client, config) + return cls(ResourcesApi(api_client), api_client) + + async def close(self) -> None: + await self.api_client.close() + + async def create_resource(self, name: str) -> str: + resource = await self.api.create_resource(Resource(name=name)) + if not isinstance(resource.id, str) or not resource.id: + raise AssertionError("Created resource has no valid ID") + return resource.id + + async def get_resource(self, resource_id: str) -> str: + resource = await self.api.get_resource(resource_id) + if not isinstance(resource.id, str) or not resource.id: + raise AssertionError("Fetched resource has no valid ID") + return resource.id + + async def delete_resource(self, resource_id: str) -> None: + await self.api.delete_resource(resource_id) diff --git a/tests/integration/support/lifecycle_clients/openapi_httpx.py b/tests/integration/support/lifecycle_clients/openapi_httpx.py new file mode 100644 index 0000000..03b3207 --- /dev/null +++ b/tests/integration/support/lifecycle_clients/openapi_httpx.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from generated_httpx_test_client import ( # type: ignore[import-not-found] + ApiClient, + Configuration, + Resource, + ResourcesApi, +) + +from mastercard_oauth2_client import OAuth2Config +from mastercard_oauth2_client.integrations.openapi.httpx import add_oauth2_layer + + +@dataclass(slots=True) +class OpenApiHttpxLifecycleClient: + """Run the shared lifecycle through a generated Python/HTTPX client.""" + + api: ResourcesApi + api_client: ApiClient + + @classmethod + def create(cls, base_url: str, ca_file: str, config: OAuth2Config) -> OpenApiHttpxLifecycleClient: + generated_config = Configuration(host=base_url) + generated_config.ssl_ca_cert = ca_file + generated_config.proxy = None + api_client = ApiClient(generated_config) + add_oauth2_layer(api_client, config) + return cls(ResourcesApi(api_client), api_client) + + async def close(self) -> None: + await self.api_client.close() + + async def create_resource(self, name: str) -> str: + resource = await self.api.create_resource(Resource(name=name)) + if not isinstance(resource.id, str) or not resource.id: + raise AssertionError("Created resource has no valid ID") + return resource.id + + async def get_resource(self, resource_id: str) -> str: + resource = await self.api.get_resource(resource_id) + if not isinstance(resource.id, str) or not resource.id: + raise AssertionError("Fetched resource has no valid ID") + return resource.id + + async def delete_resource(self, resource_id: str) -> None: + await self.api.delete_resource(resource_id) diff --git a/tests/integration/support/lifecycle_clients/openapi_urllib3.py b/tests/integration/support/lifecycle_clients/openapi_urllib3.py new file mode 100644 index 0000000..a3a6f73 --- /dev/null +++ b/tests/integration/support/lifecycle_clients/openapi_urllib3.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from generated_test_client import ApiClient, Configuration, Resource, ResourcesApi # type: ignore[import-not-found] + +from mastercard_oauth2_client import OAuth2Config, add_oauth2_layer + + +@dataclass(slots=True) +class OpenApiLifecycleClient: + """Run the shared lifecycle through the generated Python/urllib3 client.""" + + api: ResourcesApi + + @classmethod + def create(cls, base_url: str, ca_file: str, config: OAuth2Config) -> OpenApiLifecycleClient: + generated_config = Configuration(host=base_url) + generated_config.ssl_ca_cert = ca_file + generated_config.proxy = None + api_client = ApiClient(generated_config) + add_oauth2_layer(api_client, config) + return cls(ResourcesApi(api_client)) + + def create_resource(self, name: str) -> str: + resource = self.api.create_resource(Resource(name=name)) + if not isinstance(resource.id, str) or not resource.id: + raise AssertionError("Created resource has no valid ID") + return resource.id + + def get_resource(self, resource_id: str) -> str: + resource = self.api.get_resource(resource_id) + if not isinstance(resource.id, str) or not resource.id: + raise AssertionError("Fetched resource has no valid ID") + return resource.id + + def delete_resource(self, resource_id: str) -> None: + self.api.delete_resource(resource_id) diff --git a/tests/integration/support/models.py b/tests/integration/support/models.py new file mode 100644 index 0000000..74016f5 --- /dev/null +++ b/tests/integration/support/models.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from threading import Barrier +from typing import Literal + +from mastercard_oauth2_client.models import PublicKey + + +@dataclass(frozen=True, slots=True) +class RecordedRequest: + method: str + path: str + headers: dict[str, tuple[str, ...]] + body: bytes + client_port: int + + def header(self, name: str) -> str | None: + values = self.headers.get(name.casefold(), ()) + return values[0] if len(values) == 1 else None + + +@dataclass(slots=True) +class TokenBinding: + jkt: str | None = None + + +@dataclass(slots=True) +class AuthorizationState: + client_id: str + key_id: str + client_public_key: PublicKey + scopes: frozenset[str] + binding: TokenBinding + requests: list[RecordedRequest] = field(default_factory=list) + nonce: str = "authorization-nonce" + access_token: str = "integration-access-token" + reject_client_assertion: bool = False + always_challenge_nonce: bool = False + initial_token_request_barrier: Barrier | None = None + + def reset(self) -> None: + self.requests.clear() + self.binding.jkt = None + self.reject_client_assertion = False + self.always_challenge_nonce = False + self.initial_token_request_barrier = None + + +@dataclass(slots=True) +class ResourceState: + binding: TokenBinding + requests: list[RecordedRequest] = field(default_factory=list) + resources: dict[str, dict[str, object]] = field(default_factory=dict) + nonce: str = "resource-nonce" + access_token: str = "integration-access-token" + next_id: int = 1 + insufficient_scope: bool = False + always_challenge_nonce: bool = False + fail_get: bool = False + + def reset(self) -> None: + self.requests.clear() + self.resources.clear() + self.next_id = 1 + self.insufficient_scope = False + self.always_challenge_nonce = False + self.fail_get = False + + +type DPoPAlgorithm = Literal["ec", "rsa"] diff --git a/tests/integration/support/resource_server.py b/tests/integration/support/resource_server.py new file mode 100644 index 0000000..fc46e82 --- /dev/null +++ b/tests/integration/support/resource_server.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import json +import ssl +from collections.abc import Mapping +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread +from typing import Self +from urllib.parse import urlsplit + +from .assertions import JwtValidationError, access_token_hash, jwk_thumbprint, validate_time_claims, verify_jwt +from .models import RecordedRequest, ResourceState +from .tls import TlsFiles + +RESOURCE_COLLECTION_PATH = "/api/resources" +PAYLOAD_ECHO_PATH = "/api/echo" +_RESOURCE_ITEM_PREFIX = f"{RESOURCE_COLLECTION_PATH}/" + + +class _ResourceHttpServer(ThreadingHTTPServer): + daemon_threads = True + state: ResourceState + + +class _ResourceHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_GET(self) -> None: + self._handle() + + def do_POST(self) -> None: + self._handle() + + def do_DELETE(self) -> None: + self._handle() + + def log_message(self, format: str, *args: object) -> None: + return + + def _handle(self) -> None: + server = self.server + if not isinstance(server, _ResourceHttpServer): + raise TypeError("Unexpected resource server") + request = _record_request(self) + server.state.requests.append(request) + try: + self._validate_request(server.state, request) + except JwtValidationError as error: + self._send_json(401, {"error": "invalid_dpop_proof", "error_description": str(error)}) + return + except ValueError as error: + self._send_json(401, {"error": "invalid_token", "error_description": str(error)}) + return + + if server.state.insufficient_scope: + self._send_json( + 403, + {"error": "insufficient_scope", "error_description": "requested scope is not permitted"}, + {"WWW-Authenticate": 'DPoP error="insufficient_scope"'}, + ) + return + + dpop = verify_jwt(_required_header(request, "dpop")) + if server.state.always_challenge_nonce or dpop.claims.get("nonce") != server.state.nonce: + self._send_json( + 401, + {"error": "use_dpop_nonce"}, + { + "DPoP-Nonce": server.state.nonce, + "WWW-Authenticate": 'DPoP error="use_dpop_nonce"', + }, + ) + return + + self._handle_resource(server.state, request) + + def _validate_request(self, state: ResourceState, request: RecordedRequest) -> None: + _require_single_headers(request, ("authorization", "dpop", "user-agent")) + if request.header("authorization") != f"DPoP {state.access_token}": + raise ValueError("Access token is invalid") + if request.method == "GET" and request.header("accept") != "application/json": + raise ValueError("Accept header is invalid") + path = urlsplit(request.path).path + if ( + path == RESOURCE_COLLECTION_PATH + and request.method == "POST" + and request.header("content-type") != "application/json" + ): + raise ValueError("Content-Type header is invalid") + + dpop = verify_jwt(_required_header(request, "dpop")) + if dpop.header.get("typ") != "dpop+jwt": + raise JwtValidationError("DPoP typ is invalid") + dpop_jkt = jwk_thumbprint(dpop.header.get("jwk")) + if state.binding.jkt is None or dpop_jkt != state.binding.jkt or dpop.header.get("kid") != dpop_jkt: + raise JwtValidationError("DPoP token binding is invalid") + expected_htu = f"{_request_origin(request)}{urlsplit(request.path).path}" + if dpop.claims.get("htm") != request.method or dpop.claims.get("htu") != expected_htu: + raise JwtValidationError("DPoP request binding is invalid") + if dpop.claims.get("ath") != access_token_hash(state.access_token): + raise JwtValidationError("DPoP ath is invalid") + _require_string_claim(dpop.claims, "jti") + validate_time_claims(dpop.claims) + + def _handle_resource(self, state: ResourceState, request: RecordedRequest) -> None: + path = urlsplit(request.path).path + if path == PAYLOAD_ECHO_PATH: + self._handle_echo(state, request) + return + if path == RESOURCE_COLLECTION_PATH: + self._handle_collection(state, request) + return + if path.startswith(_RESOURCE_ITEM_PREFIX): + self._handle_item(state, request, path.removeprefix(_RESOURCE_ITEM_PREFIX)) + return + self._send_json(404, {"error": "not_found"}) + + def _handle_echo(self, state: ResourceState, request: RecordedRequest) -> None: + if request.method == "POST": + self._send_json(200, {"received": len(request.body)}, {"DPoP-Nonce": state.nonce}) + return + self._send_json(404, {"error": "not_found"}) + + def _handle_collection(self, state: ResourceState, request: RecordedRequest) -> None: + if request.method == "POST": + try: + value = _parse_new_resource(request.body) + except (json.JSONDecodeError, UnicodeDecodeError, ValueError): + self._send_json(400, {"error": "invalid_resource"}) + return + resource_id = str(state.next_id) + state.next_id += 1 + resource = {**value, "id": resource_id} + state.resources[resource_id] = resource + self._send_json(201, resource, {"DPoP-Nonce": state.nonce}) + return + self._send_json(404, {"error": "not_found"}) + + def _handle_item(self, state: ResourceState, request: RecordedRequest, resource_id: str) -> None: + if request.method == "GET": + if state.fail_get: + self._send_json(500, {"error": "resource_server_error"}) + return + stored_resource = state.resources.get(resource_id) + if stored_resource is None: + self._send_json(404, {"error": "not_found"}) + else: + self._send_json(200, stored_resource, {"DPoP-Nonce": state.nonce}) + return + if request.method == "DELETE": + if state.resources.pop(resource_id, None) is None: + self._send_json(404, {"error": "not_found"}) + else: + self._send_empty(204, {"DPoP-Nonce": state.nonce}) + return + self._send_json(HTTPStatus.METHOD_NOT_ALLOWED, {"error": "method_not_allowed"}) + + def _send_json(self, status: int, value: object, headers: Mapping[str, str] | None = None) -> None: + body = json.dumps(value, separators=(",", ":")).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + for name, header_value in (headers or {}).items(): + self.send_header(name, header_value) + self.end_headers() + self.wfile.write(body) + + def _send_empty(self, status: int, headers: Mapping[str, str] | None = None) -> None: + self.send_response(status) + self.send_header("Content-Length", "0") + for name, header_value in (headers or {}).items(): + self.send_header(name, header_value) + self.end_headers() + + +class FakeResourceServer: + """Local HTTPS resource API that validates DPoP and stores fake resources.""" + + def __init__(self, state: ResourceState, tls_files: TlsFiles) -> None: + self.state = state + self._server = _ResourceHttpServer(("127.0.0.1", 0), _ResourceHandler) + self._server.state = state + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(tls_files.certificate, tls_files.private_key) + self._server.socket = context.wrap_socket(self._server.socket, server_side=True) + self._thread = Thread(target=self._server.serve_forever, daemon=True) + + @property + def base_url(self) -> str: + return f"https://127.0.0.1:{self._server.server_port}" + + def __enter__(self) -> Self: + self._thread.start() + return self + + def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None: + self._server.shutdown() + self._server.server_close() + self._thread.join() + + +def _record_request(handler: BaseHTTPRequestHandler) -> RecordedRequest: + content_length = int(handler.headers.get("Content-Length", "0")) + headers = { + name.casefold(): tuple(handler.headers.get_all(name, failobj=[])) + for name in dict(handler.headers) + } + return RecordedRequest( + method=handler.command, + path=handler.path, + headers=headers, + body=handler.rfile.read(content_length), + client_port=handler.client_address[1], + ) + + +def _required_header(request: RecordedRequest, name: str) -> str: + value = request.header(name) + if value is None: + raise ValueError(f"Missing or duplicate header: {name}") + return value + + +def _require_single_headers(request: RecordedRequest, names: tuple[str, ...]) -> None: + for name in names: + _required_header(request, name) + + +def _require_string_claim(claims: dict[str, object], name: str) -> str: + value = claims.get(name) + if not isinstance(value, str) or not value: + raise JwtValidationError(f"JWT {name} claim is invalid") + return value + + +def _request_origin(request: RecordedRequest) -> str: + return f"https://{_required_header(request, 'host')}" + + +def _parse_new_resource(body: bytes) -> dict[str, object]: + """Accept only payloads allowed by the fake OpenAPI Resource schema.""" + + value = json.loads(body) + if not isinstance(value, dict) or set(value) != {"name"}: + raise ValueError("Resource payload must contain only name") + name = value.get("name") + if not isinstance(name, str) or not 1 <= len(name) <= 200: + raise ValueError("Resource name is invalid") + return {"name": name} diff --git a/tests/integration/support/stores.py b/tests/integration/support/stores.py new file mode 100644 index 0000000..67fc05b --- /dev/null +++ b/tests/integration/support/stores.py @@ -0,0 +1,21 @@ +from mastercard_oauth2_client import AccessToken, AccessTokenFilter, InMemoryTokenStore + + +class InvalidatableTokenStore: + """Test store that can force a cache miss without changing wall-clock time.""" + + def __init__(self) -> None: + self._delegate = InMemoryTokenStore() + self._expired = False + + def put(self, access_token: AccessToken) -> None: + self._delegate.put(access_token) + self._expired = False + + def get(self, token_filter: AccessTokenFilter) -> AccessToken | None: + if self._expired: + return None + return self._delegate.get(token_filter) + + def invalidate(self) -> None: + self._expired = True diff --git a/tests/integration/support/tls.py b/tests/integration/support/tls.py new file mode 100644 index 0000000..5785374 --- /dev/null +++ b/tests/integration/support/tls.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import ipaddress +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + + +@dataclass(frozen=True, slots=True) +class TlsFiles: + certificate: Path + private_key: Path + + +def create_tls_files(directory: Path) -> TlsFiles: + """Create a one-day self-signed certificate trusted only by test clients.""" + + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]) + now = datetime.now(UTC) + certificate = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(subject) + .public_key(private_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=1)) + .not_valid_after(now + timedelta(days=1)) + .add_extension( + x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), + critical=False, + ) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .sign(private_key, hashes.SHA256()) + ) + + certificate_path = directory / "certificate.pem" + private_key_path = directory / "private-key.pem" + certificate_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM)) + private_key_path.write_bytes( + private_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + ) + return TlsFiles(certificate=certificate_path, private_key=private_key_path) diff --git a/tests/integration/test_aiohttp.py b/tests/integration/test_aiohttp.py new file mode 100644 index 0000000..1c56cb1 --- /dev/null +++ b/tests/integration/test_aiohttp.py @@ -0,0 +1,412 @@ +from __future__ import annotations + +import asyncio +import email +import io +import ssl +from collections import Counter +from collections.abc import AsyncIterator +from threading import Barrier +from urllib.parse import parse_qs + +import aiohttp +import aiohttp_retry +import pytest + +from mastercard_oauth2_client import OAuth2Config +from mastercard_oauth2_client.integrations.aiohttp import OAuth2Middleware +from tests.integration.support.environment import OAuthTestEnvironment +from tests.integration.support.lifecycle import run_async_resource_lifecycle +from tests.integration.support.lifecycle_clients.aiohttp import AiohttpLifecycleClient +from tests.integration.support.models import DPoPAlgorithm +from tests.integration.support.resource_server import PAYLOAD_ECHO_PATH +from tests.integration.support.stores import InvalidatableTokenStore + +pytestmark = pytest.mark.aiohttp + + +def _client( + environment: OAuthTestEnvironment, + algorithm: DPoPAlgorithm = "ec", + *, + config: OAuth2Config | None = None, +) -> aiohttp.ClientSession: + ssl_context = ssl.create_default_context(cafile=str(environment.tls_files.certificate)) + return aiohttp.ClientSession( + connector=aiohttp.TCPConnector(ssl=ssl_context), + middlewares=(OAuth2Middleware(config or environment.config(algorithm)),), + ) + + +@pytest.mark.parametrize("dpop_algorithm", ["ec", "rsa"]) +def test_aiohttp_client_completes_stateful_https_lifecycle( + oauth_environment: OAuthTestEnvironment, + dpop_algorithm: DPoPAlgorithm, +) -> None: + async def exercise() -> str: + async with _client(oauth_environment, dpop_algorithm) as client: + lifecycle_client = AiohttpLifecycleClient(client, oauth_environment.resource.base_url) + return await run_async_resource_lifecycle(lifecycle_client) + + resource_id = asyncio.run(exercise()) + + assert resource_id == "1" + assert oauth_environment.resource.state.resources == {} + assert [request.method for request in oauth_environment.authorization.state.requests] == ["POST", "POST"] + assert [request.method for request in oauth_environment.resource.state.requests] == [ + "POST", + "POST", + "GET", + "DELETE", + ] + assert len({request.client_port for request in oauth_environment.authorization.state.requests}) == 1 + assert len({request.client_port for request in oauth_environment.resource.state.requests}) == 1 + + +def test_aiohttp_lifecycle_attempts_delete_after_get_failure( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> None: + async with _client(oauth_environment) as client: + lifecycle_client = AiohttpLifecycleClient(client, oauth_environment.resource.base_url) + await run_async_resource_lifecycle(lifecycle_client) + + oauth_environment.resource.state.fail_get = True + + with pytest.raises(aiohttp.ClientResponseError) as error: + asyncio.run(exercise()) + + assert error.value.status == 500 + assert oauth_environment.resource.state.resources == {} + assert [request.method for request in oauth_environment.resource.state.requests][-2:] == ["GET", "DELETE"] + + +def test_aiohttp_client_buffers_streaming_body_for_nonce_retry( + oauth_environment: OAuthTestEnvironment, +) -> None: + body = b'{"name":"streamed-resource"}' + + async def body_stream() -> AsyncIterator[bytes]: + yield body[:10] + yield body[10:] + + async def exercise() -> aiohttp.ClientResponse: + async with _client(oauth_environment) as client: + return await client.post( + f"{oauth_environment.resource.base_url}/api/resources", + headers={"Content-Type": "application/json"}, + data=body_stream(), + ) + + response = asyncio.run(exercise()) + + assert response.status == 201 + assert [request.body for request in oauth_environment.resource.state.requests] == [body, body] + + +def test_aiohttp_client_preserves_form_body_for_nonce_retry( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> int: + async with _client(oauth_environment) as client: + response = await client.post( + f"{oauth_environment.resource.base_url}{PAYLOAD_ECHO_PATH}", + data={"name": "form resource", "tag": ["first", "second"]}, + ) + await response.read() + return response.status + + assert asyncio.run(exercise()) == 200 + requests = oauth_environment.resource.state.requests + assert len(requests) == 2 + assert [request.header("content-type") for request in requests] == [ + "application/x-www-form-urlencoded", + "application/x-www-form-urlencoded", + ] + assert [parse_qs(request.body.decode()) for request in requests] == [ + {"name": ["form resource"], "tag": ["first", "second"]}, + {"name": ["form resource"], "tag": ["first", "second"]}, + ] + + +def test_aiohttp_client_preserves_multipart_file_for_nonce_retry( + oauth_environment: OAuthTestEnvironment, +) -> None: + file_body = b"multipart-file-content" + + async def exercise() -> int: + form = aiohttp.FormData() + form.add_field("description", "multipart resource") + form.add_field("document", io.BytesIO(file_body), filename="resource.txt", content_type="text/plain") + async with _client(oauth_environment) as client: + response = await client.post(f"{oauth_environment.resource.base_url}{PAYLOAD_ECHO_PATH}", data=form) + await response.read() + return response.status + + assert asyncio.run(exercise()) == 200 + requests = oauth_environment.resource.state.requests + assert len(requests) == 2 + assert requests[0].header("content-type") == requests[1].header("content-type") + assert [_multipart_parts(request.header("content-type"), request.body) for request in requests] == [ + {"description": (None, b"multipart resource"), "document": ("resource.txt", file_body)}, + {"description": (None, b"multipart resource"), "document": ("resource.txt", file_body)}, + ] + + +def test_aiohttp_client_preserves_query_json_and_caller_headers( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> tuple[int, dict[str, object]]: + async with _client(oauth_environment) as client: + response = await client.post( + f"{oauth_environment.resource.base_url}/api/resources", + params={"source": "aiohttp"}, + json={"name": "aiohttp-resource"}, + headers={ + "Accept": "application/json", + "User-Agent": "caller-agent", + "Authorization": "Bearer stale", + "DPoP": "stale-proof", + }, + ) + return response.status, await response.json() + + status, response_body = asyncio.run(exercise()) + recorded = oauth_environment.resource.state.requests[-1] + + assert status == 201 + assert response_body == {"id": "1", "name": "aiohttp-resource"} + assert recorded.path == "/api/resources?source=aiohttp" + assert recorded.body == b'{"name": "aiohttp-resource"}' + assert recorded.header("user-agent") == "caller-agent" + assert recorded.header("authorization") == "DPoP integration-access-token" + assert recorded.header("dpop") != "stale-proof" + + +def test_aiohttp_client_reuses_cached_token(oauth_environment: OAuthTestEnvironment) -> None: + async def exercise() -> tuple[int, int]: + async with _client(oauth_environment) as client: + headers = {"Accept": "application/json"} + first = await client.get(f"{oauth_environment.resource.base_url}/api/resources/missing", headers=headers) + second = await client.get(f"{oauth_environment.resource.base_url}/api/resources/missing", headers=headers) + return first.status, second.status + + assert asyncio.run(exercise()) == (404, 404) + assert len(oauth_environment.authorization.state.requests) == 2 + + +def test_aiohttp_client_reacquires_invalidated_token(oauth_environment: OAuthTestEnvironment) -> None: + async def exercise(config: OAuth2Config, store: InvalidatableTokenStore) -> None: + async with _client(oauth_environment, config=config) as client: + headers = {"Accept": "application/json"} + await client.get(f"{oauth_environment.resource.base_url}/api/resources/missing", headers=headers) + store.invalidate() + await client.get(f"{oauth_environment.resource.base_url}/api/resources/missing", headers=headers) + + store = InvalidatableTokenStore() + asyncio.run(exercise(oauth_environment.config("ec", token_store=store), store)) + + assert len(oauth_environment.authorization.state.requests) == 4 + + +def test_aiohttp_client_allows_concurrent_cache_misses(oauth_environment: OAuthTestEnvironment) -> None: + async def exercise() -> list[int]: + async with _client(oauth_environment) as client: + responses = await asyncio.gather( + client.get( + f"{oauth_environment.resource.base_url}/api/resources/first", + headers={"Accept": "application/json"}, + ), + client.get( + f"{oauth_environment.resource.base_url}/api/resources/second", + headers={"Accept": "application/json"}, + ), + ) + statuses = [response.status for response in responses] + await asyncio.gather(*(response.read() for response in responses)) + return statuses + + oauth_environment.authorization.state.initial_token_request_barrier = Barrier(2) + + assert asyncio.run(exercise()) == [404, 404] + assert len(oauth_environment.authorization.state.requests) == 4 + resource_paths = Counter(request.path for request in oauth_environment.resource.state.requests) + assert set(resource_paths) == {"/api/resources/first", "/api/resources/second"} + assert all(attempts in {1, 2} for attempts in resource_paths.values()) + + +def test_aiohttp_client_stops_after_one_token_nonce_retry( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> int: + async with _client(oauth_environment) as client: + response = await client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + return response.status + + oauth_environment.authorization.state.always_challenge_nonce = True + + assert asyncio.run(exercise()) == 400 + assert len(oauth_environment.authorization.state.requests) == 2 + assert oauth_environment.resource.state.requests == [] + + +def test_aiohttp_client_returns_native_token_error(oauth_environment: OAuthTestEnvironment) -> None: + async def exercise() -> tuple[aiohttp.ClientResponse, dict[str, str]]: + async with _client(oauth_environment) as client: + response = await client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + return response, await response.json() + + oauth_environment.authorization.state.reject_client_assertion = True + response, body = asyncio.run(exercise()) + + assert isinstance(response, aiohttp.ClientResponse) + assert response.status == 400 + assert body == { + "error": "invalid_client", + "error_description": "Client assertion was rejected", + } + assert oauth_environment.resource.state.requests == [] + + +def test_aiohttp_client_returns_resource_server_error(oauth_environment: OAuthTestEnvironment) -> None: + async def exercise() -> tuple[int, dict[str, str], str | None]: + async with _client(oauth_environment) as client: + response = await client.post( + f"{oauth_environment.resource.base_url}/api/resources", + json={"name": "forbidden"}, + ) + return response.status, await response.json(), response.headers.get("WWW-Authenticate") + + oauth_environment.resource.state.insufficient_scope = True + status, body, challenge = asyncio.run(exercise()) + + assert status == 403 + assert body == { + "error": "insufficient_scope", + "error_description": "requested scope is not permitted", + } + assert challenge == 'DPoP error="insufficient_scope"' + + +def test_aiohttp_client_stops_after_one_resource_nonce_retry( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> int: + async with _client(oauth_environment) as client: + response = await client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + return response.status + + oauth_environment.resource.state.always_challenge_nonce = True + + assert asyncio.run(exercise()) == 401 + assert len(oauth_environment.resource.state.requests) == 2 + + +def test_aiohttp_final_response_remains_caller_owned(oauth_environment: OAuthTestEnvironment) -> None: + async def exercise() -> bytes: + async with _client(oauth_environment) as client: + response = await client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + assert not response.content.at_eof() + return await response.read() + + assert asyncio.run(exercise()) == b'{"error":"not_found"}' + + +def test_aiohttp_middleware_order_exposes_each_resource_retry( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> tuple[list[str], list[str]]: + outer_calls: list[str] = [] + inner_calls: list[str] = [] + + async def outer(request: aiohttp.ClientRequest, handler: aiohttp.ClientHandlerType) -> aiohttp.ClientResponse: + outer_calls.append(str(request.url)) + return await handler(request) + + async def inner(request: aiohttp.ClientRequest, handler: aiohttp.ClientHandlerType) -> aiohttp.ClientResponse: + inner_calls.append(str(request.url)) + return await handler(request) + + ssl_context = ssl.create_default_context(cafile=str(oauth_environment.tls_files.certificate)) + async with aiohttp.ClientSession( + connector=aiohttp.TCPConnector(ssl=ssl_context), + middlewares=(outer, OAuth2Middleware(oauth_environment.config("ec")), inner), + ) as client: + response = await client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + await response.read() + return outer_calls, inner_calls + + outer_calls, inner_calls = asyncio.run(exercise()) + + resource_url = f"{oauth_environment.resource.base_url}/api/resources/missing" + assert outer_calls == [resource_url] + assert inner_calls == [resource_url, resource_url] + + +def test_aiohttp_middleware_does_not_own_session(oauth_environment: OAuthTestEnvironment) -> None: + async def exercise() -> tuple[bool, bool]: + client = _client(oauth_environment) + response = await client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + await response.read() + open_after_request = not client.closed + await client.close() + return open_after_request, client.closed + + assert asyncio.run(exercise()) == (True, True) + + +def test_aiohttp_retry_client_uses_oauth2_session_middleware( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> int: + async with _client(oauth_environment) as client: + retry_client = aiohttp_retry.RetryClient( + client_session=client, + retry_options=aiohttp_retry.ExponentialRetry(attempts=2), + ) + response = await retry_client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + await response.read() + await retry_client.close() + return response.status + + assert asyncio.run(exercise()) == 404 + assert len(oauth_environment.authorization.state.requests) == 2 + + +def _multipart_parts(content_type: str | None, body: bytes) -> dict[str, tuple[str | None, bytes]]: + if content_type is None: + raise AssertionError("Multipart request must have Content-Type") + message = email.message_from_bytes(f"Content-Type: {content_type}\r\n\r\n".encode() + body) + if not message.is_multipart(): + raise AssertionError("Request body must be multipart") + parts: dict[str, tuple[str | None, bytes]] = {} + for part in message.walk(): + if part.is_multipart(): + continue + name = part.get_param("name", header="content-disposition") + payload = part.get_payload(decode=True) + if not isinstance(name, str) or not isinstance(payload, bytes): + raise TypeError("Multipart part must have a name and byte payload") + parts[name] = (part.get_filename(), payload) + return parts diff --git a/tests/integration/test_httpx.py b/tests/integration/test_httpx.py new file mode 100644 index 0000000..61acbba --- /dev/null +++ b/tests/integration/test_httpx.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import ssl + +import httpx +import pytest + +from mastercard_oauth2_client import OAuth2Config +from mastercard_oauth2_client.integrations.httpx import OAuth2Transport +from tests.integration.support.environment import OAuthTestEnvironment +from tests.integration.support.lifecycle import run_resource_lifecycle +from tests.integration.support.lifecycle_clients.httpx import HttpxLifecycleClient +from tests.integration.support.models import DPoPAlgorithm +from tests.integration.support.stores import InvalidatableTokenStore + +pytestmark = pytest.mark.httpx + + +def _client( + environment: OAuthTestEnvironment, + algorithm: DPoPAlgorithm = "ec", + *, + config: OAuth2Config | None = None, +) -> httpx.Client: + ssl_context = ssl.create_default_context(cafile=str(environment.tls_files.certificate)) + transport = OAuth2Transport( + config or environment.config(algorithm), + httpx.HTTPTransport(verify=ssl_context), + ) + return httpx.Client(transport=transport) + + +@pytest.mark.parametrize("dpop_algorithm", ["ec", "rsa"]) +def test_httpx_client_completes_stateful_https_lifecycle( + oauth_environment: OAuthTestEnvironment, + dpop_algorithm: DPoPAlgorithm, +) -> None: + with _client(oauth_environment, dpop_algorithm) as client: + resource_id = run_resource_lifecycle(HttpxLifecycleClient(client, oauth_environment.resource.base_url)) + + assert resource_id == "1" + assert oauth_environment.resource.state.resources == {} + assert [request.method for request in oauth_environment.authorization.state.requests] == ["POST", "POST"] + assert [request.method for request in oauth_environment.resource.state.requests] == [ + "POST", + "POST", + "GET", + "DELETE", + ] + assert len({request.client_port for request in oauth_environment.authorization.state.requests}) == 1 + assert len({request.client_port for request in oauth_environment.resource.state.requests}) == 1 + + +def test_httpx_lifecycle_attempts_delete_after_get_failure(oauth_environment: OAuthTestEnvironment) -> None: + oauth_environment.resource.state.fail_get = True + + with _client(oauth_environment) as client, pytest.raises(httpx.HTTPStatusError) as error: + run_resource_lifecycle(HttpxLifecycleClient(client, oauth_environment.resource.base_url)) + + assert error.value.response.status_code == 500 + assert oauth_environment.resource.state.resources == {} + assert [request.method for request in oauth_environment.resource.state.requests][-2:] == ["GET", "DELETE"] + + +def test_httpx_client_preserves_query_json_and_caller_headers(oauth_environment: OAuthTestEnvironment) -> None: + with _client(oauth_environment) as client: + create_response = client.post( + f"{oauth_environment.resource.base_url}/api/resources", + params={"source": "httpx"}, + json={"name": "httpx-resource"}, + headers={ + "Accept": "application/json", + "User-Agent": "caller-agent", + "Authorization": "Bearer stale", + "DPoP": "stale-proof", + }, + ) + create_response.raise_for_status() + resource_id = create_response.json()["id"] + try: + recorded = oauth_environment.resource.state.requests[-1] + assert recorded.path == "/api/resources?source=httpx" + assert recorded.body == b'{"name":"httpx-resource"}' + assert recorded.header("user-agent") == "caller-agent" + assert recorded.header("authorization") == "DPoP integration-access-token" + assert recorded.header("dpop") != "stale-proof" + assert create_response.request.url.query == b"source=httpx" + assert create_response.request.headers["Authorization"] == "DPoP integration-access-token" + assert create_response.history == [] + finally: + client.delete(f"{oauth_environment.resource.base_url}/api/resources/{resource_id}").raise_for_status() + + +def test_httpx_client_reuses_cached_token(oauth_environment: OAuthTestEnvironment) -> None: + with _client(oauth_environment) as client: + first = client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + second = client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + + assert first.status_code == 404 + assert second.status_code == 404 + assert len(oauth_environment.authorization.state.requests) == 2 + + +def test_httpx_client_reacquires_invalidated_token(oauth_environment: OAuthTestEnvironment) -> None: + store = InvalidatableTokenStore() + config = oauth_environment.config("ec", token_store=store) + with _client(oauth_environment, config=config) as client: + first = client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + store.invalidate() + second = client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + + assert first.status_code == 404 + assert second.status_code == 404 + assert len(oauth_environment.authorization.state.requests) == 4 + + +def test_httpx_client_stops_after_one_token_nonce_retry(oauth_environment: OAuthTestEnvironment) -> None: + oauth_environment.authorization.state.always_challenge_nonce = True + + with _client(oauth_environment) as client: + response = client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + + assert response.status_code == 400 + assert response.history == [] + assert len(oauth_environment.authorization.state.requests) == 2 + assert oauth_environment.resource.state.requests == [] + + +def test_httpx_client_returns_native_token_error_without_history(oauth_environment: OAuthTestEnvironment) -> None: + oauth_environment.authorization.state.reject_client_assertion = True + + with _client(oauth_environment) as client: + response = client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + + assert isinstance(response, httpx.Response) + assert response.status_code == 400 + assert response.history == [] + assert response.json() == { + "error": "invalid_client", + "error_description": "Client assertion was rejected", + } + assert oauth_environment.resource.state.requests == [] + + +def test_httpx_client_returns_resource_server_error(oauth_environment: OAuthTestEnvironment) -> None: + oauth_environment.resource.state.insufficient_scope = True + + with _client(oauth_environment) as client: + response = client.post( + f"{oauth_environment.resource.base_url}/api/resources", + json={"name": "forbidden"}, + ) + + assert response.status_code == 403 + assert response.json() == { + "error": "insufficient_scope", + "error_description": "requested scope is not permitted", + } + assert response.headers["WWW-Authenticate"] == 'DPoP error="insufficient_scope"' + + +def test_httpx_client_stops_after_one_resource_nonce_retry(oauth_environment: OAuthTestEnvironment) -> None: + oauth_environment.resource.state.always_challenge_nonce = True + + with _client(oauth_environment) as client: + response = client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + + assert response.status_code == 401 + assert response.history == [] + assert len(oauth_environment.resource.state.requests) == 2 + + +def test_httpx_streaming_response_remains_caller_owned(oauth_environment: OAuthTestEnvironment) -> None: + with _client(oauth_environment) as client, client.stream( + "GET", + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) as response: + assert response.status_code == 404 + assert not response.is_stream_consumed + assert response.read() == b'{"error":"not_found"}' + + +def test_httpx_client_preserves_resource_event_hooks(oauth_environment: OAuthTestEnvironment) -> None: + request_urls: list[str] = [] + response_statuses: list[int] = [] + ssl_context = ssl.create_default_context(cafile=str(oauth_environment.tls_files.certificate)) + transport = OAuth2Transport( + oauth_environment.config("ec"), + httpx.HTTPTransport(verify=ssl_context), + ) + + with httpx.Client( + transport=transport, + event_hooks={ + "request": [lambda request: request_urls.append(str(request.url))], + "response": [lambda response: response_statuses.append(response.status_code)], + }, + ) as client: + response = client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + + assert response.status_code == 404 + assert request_urls == [f"{oauth_environment.resource.base_url}/api/resources/missing"] + assert response_statuses == [404] + + +def test_httpx_transport_closes_wrapped_transport(oauth_environment: OAuthTestEnvironment) -> None: + class TrackingTransport(httpx.BaseTransport): + closed = False + + def handle_request(self, request: httpx.Request) -> httpx.Response: + return httpx.Response(200, request=request) + + def close(self) -> None: + self.closed = True + + wrapped = TrackingTransport() + transport = OAuth2Transport(oauth_environment.config("ec"), wrapped) + + transport.close() + + assert wrapped.closed diff --git a/tests/integration/test_httpx_async.py b/tests/integration/test_httpx_async.py new file mode 100644 index 0000000..fd354ed --- /dev/null +++ b/tests/integration/test_httpx_async.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +import asyncio +import ssl + +import httpx +import pytest + +from mastercard_oauth2_client import OAuth2Config +from mastercard_oauth2_client.integrations.httpx import AsyncOAuth2Transport +from tests.integration.support.environment import OAuthTestEnvironment +from tests.integration.support.lifecycle import run_async_resource_lifecycle +from tests.integration.support.lifecycle_clients.httpx import AsyncHttpxLifecycleClient +from tests.integration.support.models import DPoPAlgorithm, RecordedRequest +from tests.integration.support.stores import InvalidatableTokenStore + +pytestmark = pytest.mark.httpx + + +def _client( + environment: OAuthTestEnvironment, + algorithm: DPoPAlgorithm = "ec", + *, + config: OAuth2Config | None = None, +) -> httpx.AsyncClient: + ssl_context = ssl.create_default_context(cafile=str(environment.tls_files.certificate)) + transport = AsyncOAuth2Transport( + config or environment.config(algorithm), + httpx.AsyncHTTPTransport(verify=ssl_context), + ) + return httpx.AsyncClient(transport=transport) + + +@pytest.mark.parametrize("dpop_algorithm", ["ec", "rsa"]) +def test_async_httpx_client_completes_stateful_https_lifecycle( + oauth_environment: OAuthTestEnvironment, + dpop_algorithm: DPoPAlgorithm, +) -> None: + async def exercise() -> str: + async with _client(oauth_environment, dpop_algorithm) as client: + lifecycle_client = AsyncHttpxLifecycleClient(client, oauth_environment.resource.base_url) + return await run_async_resource_lifecycle(lifecycle_client) + + resource_id = asyncio.run(exercise()) + + assert resource_id == "1" + assert oauth_environment.resource.state.resources == {} + assert [request.method for request in oauth_environment.authorization.state.requests] == ["POST", "POST"] + assert [request.method for request in oauth_environment.resource.state.requests] == [ + "POST", + "POST", + "GET", + "DELETE", + ] + assert len({request.client_port for request in oauth_environment.authorization.state.requests}) == 1 + assert len({request.client_port for request in oauth_environment.resource.state.requests}) == 1 + + +def test_async_httpx_lifecycle_attempts_delete_after_get_failure( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> None: + async with _client(oauth_environment) as client: + lifecycle_client = AsyncHttpxLifecycleClient(client, oauth_environment.resource.base_url) + await run_async_resource_lifecycle(lifecycle_client) + + oauth_environment.resource.state.fail_get = True + + with pytest.raises(httpx.HTTPStatusError) as error: + asyncio.run(exercise()) + + assert error.value.response.status_code == 500 + assert oauth_environment.resource.state.resources == {} + assert [request.method for request in oauth_environment.resource.state.requests][-2:] == ["GET", "DELETE"] + + +def test_async_httpx_client_preserves_query_json_and_caller_headers( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> tuple[httpx.Response, RecordedRequest]: + async with _client(oauth_environment) as client: + create_response = await client.post( + f"{oauth_environment.resource.base_url}/api/resources", + params={"source": "httpx-async"}, + json={"name": "httpx-async-resource"}, + headers={ + "Accept": "application/json", + "User-Agent": "caller-agent", + "Authorization": "Bearer stale", + "DPoP": "stale-proof", + }, + ) + create_response.raise_for_status() + resource_id = create_response.json()["id"] + try: + return create_response, oauth_environment.resource.state.requests[-1] + finally: + response = await client.delete(f"{oauth_environment.resource.base_url}/api/resources/{resource_id}") + response.raise_for_status() + + create_response, recorded = asyncio.run(exercise()) + assert recorded.path == "/api/resources?source=httpx-async" + assert recorded.body == b'{"name":"httpx-async-resource"}' + assert recorded.header("user-agent") == "caller-agent" + assert recorded.header("authorization") == "DPoP integration-access-token" + assert recorded.header("dpop") != "stale-proof" + assert create_response.request.url.query == b"source=httpx-async" + assert create_response.request.headers["Authorization"] == "DPoP integration-access-token" + assert create_response.history == [] + + +def test_async_httpx_client_reuses_cached_token(oauth_environment: OAuthTestEnvironment) -> None: + async def exercise() -> tuple[httpx.Response, httpx.Response]: + async with _client(oauth_environment) as client: + first = await client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + second = await client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + return first, second + + first, second = asyncio.run(exercise()) + + assert first.status_code == 404 + assert second.status_code == 404 + assert len(oauth_environment.authorization.state.requests) == 2 + + +def test_async_httpx_client_reacquires_invalidated_token(oauth_environment: OAuthTestEnvironment) -> None: + async def exercise(config: OAuth2Config, store: InvalidatableTokenStore) -> tuple[httpx.Response, httpx.Response]: + async with _client(oauth_environment, config=config) as client: + first = await client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + store.invalidate() + second = await client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + return first, second + + store = InvalidatableTokenStore() + config = oauth_environment.config("ec", token_store=store) + first, second = asyncio.run(exercise(config, store)) + + assert first.status_code == 404 + assert second.status_code == 404 + assert len(oauth_environment.authorization.state.requests) == 4 + + +def test_async_httpx_client_stops_after_one_token_nonce_retry( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> httpx.Response: + async with _client(oauth_environment) as client: + return await client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + + oauth_environment.authorization.state.always_challenge_nonce = True + response = asyncio.run(exercise()) + + assert response.status_code == 400 + assert response.history == [] + assert len(oauth_environment.authorization.state.requests) == 2 + assert oauth_environment.resource.state.requests == [] + + +def test_async_httpx_client_returns_native_token_error_without_history( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> httpx.Response: + async with _client(oauth_environment) as client: + return await client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + + oauth_environment.authorization.state.reject_client_assertion = True + response = asyncio.run(exercise()) + + assert isinstance(response, httpx.Response) + assert response.status_code == 400 + assert response.history == [] + assert response.json() == { + "error": "invalid_client", + "error_description": "Client assertion was rejected", + } + assert oauth_environment.resource.state.requests == [] + + +def test_async_httpx_client_returns_resource_server_error(oauth_environment: OAuthTestEnvironment) -> None: + async def exercise() -> httpx.Response: + async with _client(oauth_environment) as client: + return await client.post( + f"{oauth_environment.resource.base_url}/api/resources", + json={"name": "forbidden"}, + ) + + oauth_environment.resource.state.insufficient_scope = True + response = asyncio.run(exercise()) + + assert response.status_code == 403 + assert response.json() == { + "error": "insufficient_scope", + "error_description": "requested scope is not permitted", + } + assert response.headers["WWW-Authenticate"] == 'DPoP error="insufficient_scope"' + + +def test_async_httpx_client_stops_after_one_resource_nonce_retry( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> httpx.Response: + async with _client(oauth_environment) as client: + return await client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + + oauth_environment.resource.state.always_challenge_nonce = True + response = asyncio.run(exercise()) + + assert response.status_code == 401 + assert response.history == [] + assert len(oauth_environment.resource.state.requests) == 2 + + +def test_async_httpx_streaming_response_remains_caller_owned( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> None: + async with _client(oauth_environment) as client, client.stream( + "GET", + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) as response: + assert response.status_code == 404 + assert not response.is_stream_consumed + assert await response.aread() == b'{"error":"not_found"}' + + asyncio.run(exercise()) + + +def test_async_httpx_client_preserves_resource_event_hooks(oauth_environment: OAuthTestEnvironment) -> None: + async def exercise() -> tuple[httpx.Response, list[str], list[int]]: + request_urls: list[str] = [] + response_statuses: list[int] = [] + + async def record_request(request: httpx.Request) -> None: + request_urls.append(str(request.url)) + + async def record_response(response: httpx.Response) -> None: + response_statuses.append(response.status_code) + + ssl_context = ssl.create_default_context(cafile=str(oauth_environment.tls_files.certificate)) + transport = AsyncOAuth2Transport( + oauth_environment.config("ec"), + httpx.AsyncHTTPTransport(verify=ssl_context), + ) + async with httpx.AsyncClient( + transport=transport, + event_hooks={"request": [record_request], "response": [record_response]}, + ) as client: + response = await client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + return response, request_urls, response_statuses + + response, request_urls, response_statuses = asyncio.run(exercise()) + + assert response.status_code == 404 + assert request_urls == [f"{oauth_environment.resource.base_url}/api/resources/missing"] + assert response_statuses == [404] + + +def test_async_httpx_transport_closes_wrapped_transport(oauth_environment: OAuthTestEnvironment) -> None: + class TrackingTransport(httpx.AsyncBaseTransport): + closed = False + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + return httpx.Response(200, request=request) + + async def aclose(self) -> None: + self.closed = True + + async def exercise() -> bool: + wrapped = TrackingTransport() + transport = AsyncOAuth2Transport(oauth_environment.config("ec"), wrapped) + await transport.aclose() + return wrapped.closed + + assert asyncio.run(exercise()) diff --git a/tests/integration/test_openapi.py b/tests/integration/test_openapi.py new file mode 100644 index 0000000..105d89c --- /dev/null +++ b/tests/integration/test_openapi.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +import json +from urllib.parse import parse_qsl, urlencode + +import pytest +import urllib3 +from generated_test_client import Resource # type: ignore[import-not-found] +from generated_test_client.exceptions import ApiException # type: ignore[import-not-found] +from urllib3._collections import HTTPHeaderDict + +from mastercard_oauth2_client import OAuth2Config, build_token_request, create_resource_dpop_proof +from tests.integration.support.environment import OAuthTestEnvironment +from tests.integration.support.lifecycle import run_resource_lifecycle +from tests.integration.support.lifecycle_clients.openapi_urllib3 import OpenApiLifecycleClient +from tests.integration.support.models import DPoPAlgorithm +from tests.integration.support.stores import InvalidatableTokenStore + +pytestmark = [pytest.mark.generated, pytest.mark.urllib3] + + +def _client( + environment: OAuthTestEnvironment, + algorithm: DPoPAlgorithm = "ec", + *, + config: OAuth2Config | None = None, +) -> OpenApiLifecycleClient: + return OpenApiLifecycleClient.create( + environment.resource.base_url, + str(environment.tls_files.certificate), + config or environment.config(algorithm), + ) + + +@pytest.mark.parametrize("dpop_algorithm", ["ec", "rsa"]) +def test_openapi_client_completes_stateful_https_lifecycle( + oauth_environment: OAuthTestEnvironment, + dpop_algorithm: DPoPAlgorithm, +) -> None: + """Prove token/resource nonce handling and POST/GET/DELETE for each DPoP key type.""" + + resource_id = run_resource_lifecycle(_client(oauth_environment, dpop_algorithm)) + + assert resource_id == "1" + assert oauth_environment.resource.state.resources == {} + assert [request.method for request in oauth_environment.authorization.state.requests] == ["POST", "POST"] + assert [request.method for request in oauth_environment.resource.state.requests] == [ + "POST", + "POST", + "GET", + "DELETE", + ] + + +def test_openapi_lifecycle_attempts_delete_after_get_failure(oauth_environment: OAuthTestEnvironment) -> None: + """Prevent failed lifecycle assertions from leaving fake resources behind.""" + + oauth_environment.resource.state.fail_get = True + + with pytest.raises(ApiException) as error: + run_resource_lifecycle(_client(oauth_environment)) + + assert error.value.status == 500 + assert oauth_environment.resource.state.resources == {} + assert [request.method for request in oauth_environment.resource.state.requests][-2:] == ["GET", "DELETE"] + + +def test_openapi_client_preserves_query_json_and_caller_headers(oauth_environment: OAuthTestEnvironment) -> None: + client = _client(oauth_environment) + client.api.api_client.user_agent = "caller-agent" + resource = client.api.create_resource( + Resource(name="openapi-resource"), + source="openapi", + _headers={ + "Authorization": "Bearer stale", + "DPoP": "stale-proof", + }, + ) + assert resource.id is not None + try: + recorded = oauth_environment.resource.state.requests[-1] + assert recorded.path == "/api/resources?source=openapi" + assert recorded.body == b'{"name": "openapi-resource"}' + assert recorded.header("user-agent") == "caller-agent" + assert recorded.header("authorization") == "DPoP integration-access-token" + assert recorded.header("dpop") != "stale-proof" + finally: + client.delete_resource(resource.id) + + +def test_openapi_without_preload_content_returns_readable_raw_response( + oauth_environment: OAuthTestEnvironment, +) -> None: + """Leave successful resource responses available to generated streaming methods.""" + + client = _client(oauth_environment) + response = client.api.create_resource_without_preload_content(Resource(name="raw-response")) + try: + payload = json.loads(response.read()) + resource_id = payload["id"] + assert response.status == 201 + assert resource_id == "1" + finally: + response.release_conn() + + client.delete_resource(resource_id) + + +def test_openapi_client_reuses_cached_token(oauth_environment: OAuthTestEnvironment) -> None: + client = _client(oauth_environment) + + for _ in range(2): + with pytest.raises(ApiException) as error: + client.get_resource("missing") + assert error.value.status == 404 + + assert len(oauth_environment.authorization.state.requests) == 2 + + +def test_openapi_client_returns_configured_server_errors(oauth_environment: OAuthTestEnvironment) -> None: + """Preserve authorization-server and resource-server failures for callers.""" + + oauth_environment.authorization.state.reject_client_assertion = True + with pytest.raises(ApiException) as assertion_error: + _client(oauth_environment).create_resource("rejected") + assert assertion_error.value.status == 400 + assert json.loads(assertion_error.value.body) == { + "error": "invalid_client", + "error_description": "Client assertion was rejected", + } + + oauth_environment.reset() + oauth_environment.resource.state.insufficient_scope = True + with pytest.raises(ApiException) as scope_error: + _client(oauth_environment).create_resource("forbidden") + assert scope_error.value.status == 403 + assert json.loads(scope_error.value.body) == { + "error": "insufficient_scope", + "error_description": "requested scope is not permitted", + } + assert scope_error.value.headers["WWW-Authenticate"] == 'DPoP error="insufficient_scope"' + + +def test_openapi_client_stops_after_one_nonce_retry(oauth_environment: OAuthTestEnvironment) -> None: + """Fail closed after one authorization-server nonce retry.""" + + oauth_environment.authorization.state.always_challenge_nonce = True + + with pytest.raises(ApiException) as error: + _client(oauth_environment).create_resource("never-created") + + assert error.value.status == 400 + assert len(oauth_environment.authorization.state.requests) == 2 + assert oauth_environment.resource.state.requests == [] + + +def test_openapi_client_stops_after_one_resource_nonce_retry(oauth_environment: OAuthTestEnvironment) -> None: + """Fail closed after one resource-server nonce retry.""" + + oauth_environment.resource.state.always_challenge_nonce = True + + with pytest.raises(ApiException) as error: + _client(oauth_environment).create_resource("never-created") + + assert error.value.status == 401 + assert len(oauth_environment.resource.state.requests) == 2 + + +def test_openapi_client_reacquires_expired_token(oauth_environment: OAuthTestEnvironment) -> None: + """Replace an unusable cached token before the next resource request.""" + + store = InvalidatableTokenStore() + config = oauth_environment.config("ec", token_store=store) + client = _client(oauth_environment, config=config) + resource_id = client.create_resource("expires") + store.invalidate() + try: + assert client.get_resource(resource_id) == resource_id + finally: + client.delete_resource(resource_id) + + # The shared handler nonce contains the resource nonce, so token reacquisition + # receives a fresh authorization-server challenge before succeeding. + assert len(oauth_environment.authorization.state.requests) == 4 + + +def test_authorization_server_rejects_tampered_client_assertion(oauth_environment: OAuthTestEnvironment) -> None: + """Prove the fake token endpoint independently checks assertion signatures.""" + + config = oauth_environment.config("ec") + dpop_key = config.dpop_key_provider.get_current_key() + request = build_token_request(config, config.scope_resolver.all_scopes(), dpop_key.key_id) + form = dict(parse_qsl(str(request.body))) + form["client_assertion"] = _tamper(form["client_assertion"]) + pool = urllib3.PoolManager(ca_certs=str(oauth_environment.tls_files.certificate)) + + response = pool.request(request.method, request.url, headers=request.headers, body=urlencode(form)) + + assert response.status == 400 + assert json.loads(response.data)["error"] == "invalid_client" + + +def test_resource_server_rejects_tampered_dpop_signature(oauth_environment: OAuthTestEnvironment) -> None: + """Prove the fake resource endpoint independently checks DPoP signatures.""" + + client = _client(oauth_environment) + resource_id = client.create_resource("tamper-target") + original = oauth_environment.resource.state.requests[-1] + authorization = original.header("authorization") + dpop = original.header("dpop") + assert authorization is not None + assert dpop is not None + headers = { + "Accept": "application/json", + "Authorization": authorization, + "DPoP": _tamper(dpop), + "User-Agent": "integration-test", + } + pool = urllib3.PoolManager(ca_certs=str(oauth_environment.tls_files.certificate)) + try: + response = pool.request( + "GET", + f"{oauth_environment.resource.base_url}/api/resources/{resource_id}", + headers=headers, + timeout=urllib3.Timeout(connect=2, read=2), + ) + assert response.status == 401 + assert json.loads(response.data)["error"] == "invalid_dpop_proof" + finally: + client.delete_resource(resource_id) + + +def test_resource_server_rejects_incorrect_ath(oauth_environment: OAuthTestEnvironment) -> None: + """Reject a validly signed proof bound to a different access token.""" + + config = oauth_environment.config("ec") + client = _client(oauth_environment, config=config) + resource_id = client.create_resource("wrong-ath") + dpop_key = config.dpop_key_provider.get_current_key() + resource_url = f"{oauth_environment.resource.base_url}/api/resources/{resource_id}" + proof = create_resource_dpop_proof( + config, + dpop_key.key_id, + "GET", + resource_url, + "different-access-token", + oauth_environment.resource.state.nonce, + ) + pool = urllib3.PoolManager(ca_certs=str(oauth_environment.tls_files.certificate)) + try: + response = pool.request( + "GET", + resource_url, + headers={ + "Accept": "application/json", + "Authorization": f"DPoP {oauth_environment.resource.state.access_token}", + "DPoP": proof, + "User-Agent": "integration-test", + }, + timeout=urllib3.Timeout(connect=2, read=2), + ) + assert response.status == 401 + assert json.loads(response.data)["error"] == "invalid_dpop_proof" + finally: + client.delete_resource(resource_id) + + +def test_resource_server_rejects_duplicate_authorization_header(oauth_environment: OAuthTestEnvironment) -> None: + """Reject ambiguous requests containing multiple Authorization values.""" + + config = oauth_environment.config("ec") + client = _client(oauth_environment, config=config) + resource_id = client.create_resource("duplicate-header") + dpop_key = config.dpop_key_provider.get_current_key() + resource_url = f"{oauth_environment.resource.base_url}/api/resources/{resource_id}" + proof = create_resource_dpop_proof( + config, + dpop_key.key_id, + "GET", + resource_url, + oauth_environment.resource.state.access_token, + oauth_environment.resource.state.nonce, + ) + headers = HTTPHeaderDict( + { + "Accept": "application/json", + "DPoP": proof, + "User-Agent": "integration-test", + } + ) + headers.add("Authorization", f"DPoP {oauth_environment.resource.state.access_token}") + headers.add("Authorization", f"DPoP {oauth_environment.resource.state.access_token}") + pool = urllib3.PoolManager(ca_certs=str(oauth_environment.tls_files.certificate)) + try: + response = pool.request( + "GET", + resource_url, + headers=headers, + timeout=urllib3.Timeout(connect=2, read=2), + ) + assert response.status == 401 + assert json.loads(response.data)["error"] == "invalid_token" + finally: + client.delete_resource(resource_id) + + +def _tamper(compact_jwt: str) -> str: + encoded_header, encoded_claims, signature = compact_jwt.split(".") + replacement = "A" if signature[0] != "A" else "B" + return f"{encoded_header}.{encoded_claims}.{replacement}{signature[1:]}" diff --git a/tests/integration/test_openapi_asyncio.py b/tests/integration/test_openapi_asyncio.py new file mode 100644 index 0000000..f74eb13 --- /dev/null +++ b/tests/integration/test_openapi_asyncio.py @@ -0,0 +1,340 @@ +from __future__ import annotations + +import asyncio +import json + +import aiohttp +import aiohttp_retry +import pytest +from generated_asyncio_test_client import ( # type: ignore[import-not-found] + ApiClient as GeneratedApiClient, +) +from generated_asyncio_test_client import ( + Configuration as GeneratedConfiguration, +) +from generated_asyncio_test_client import ( + Resource, + ResourcesApi, +) +from generated_asyncio_test_client.exceptions import ApiException # type: ignore[import-not-found] + +from mastercard_oauth2_client import OAuth2Config +from mastercard_oauth2_client.integrations.openapi.asyncio import add_oauth2_layer +from tests.integration.support.environment import OAuthTestEnvironment +from tests.integration.support.lifecycle import run_async_resource_lifecycle +from tests.integration.support.lifecycle_clients.openapi_asyncio import OpenApiAsyncioLifecycleClient + +pytestmark = [pytest.mark.generated, pytest.mark.aiohttp] +from tests.integration.support.models import DPoPAlgorithm +from tests.integration.support.stores import InvalidatableTokenStore + + +def _client( + environment: OAuthTestEnvironment, + algorithm: DPoPAlgorithm = "ec", + *, + config: OAuth2Config | None = None, +) -> OpenApiAsyncioLifecycleClient: + return OpenApiAsyncioLifecycleClient.create( + environment.resource.base_url, + str(environment.tls_files.certificate), + config or environment.config(algorithm), + ) + + +@pytest.mark.parametrize("dpop_algorithm", ["ec", "rsa"]) +def test_openapi_asyncio_client_completes_stateful_https_lifecycle( + oauth_environment: OAuthTestEnvironment, + dpop_algorithm: DPoPAlgorithm, +) -> None: + async def exercise() -> str: + client = _client(oauth_environment, dpop_algorithm) + try: + return await run_async_resource_lifecycle(client) + finally: + await client.close() + + resource_id = asyncio.run(exercise()) + + assert resource_id == "1" + assert oauth_environment.resource.state.resources == {} + assert [request.method for request in oauth_environment.authorization.state.requests] == ["POST", "POST"] + assert [request.method for request in oauth_environment.resource.state.requests] == [ + "POST", + "POST", + "GET", + "DELETE", + ] + assert len({request.client_port for request in oauth_environment.authorization.state.requests}) == 1 + assert len({request.client_port for request in oauth_environment.resource.state.requests}) == 1 + + +def test_openapi_asyncio_lifecycle_attempts_delete_after_get_failure( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> None: + client = _client(oauth_environment) + try: + await run_async_resource_lifecycle(client) + finally: + await client.close() + + oauth_environment.resource.state.fail_get = True + + with pytest.raises(ApiException) as error: + asyncio.run(exercise()) + + assert error.value.status == 500 + assert oauth_environment.resource.state.resources == {} + assert [request.method for request in oauth_environment.resource.state.requests][-2:] == ["GET", "DELETE"] + + +def test_openapi_asyncio_client_preserves_query_json_and_caller_headers( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> Resource: + client = _client(oauth_environment) + client.api.api_client.user_agent = "caller-agent" + resource_id: str | None = None + try: + resource = await client.api.create_resource( + Resource(name="openapi-asyncio-resource"), + source="openapi-asyncio", + _headers={"Authorization": "Bearer stale", "DPoP": "stale-proof"}, + ) + if not isinstance(resource.id, str): + raise TypeError("Created resource has no ID") + resource_id = resource.id + return resource + finally: + if resource_id is not None: + await client.api.delete_resource(resource_id) + await client.close() + + resource = asyncio.run(exercise()) + recorded = oauth_environment.resource.state.requests[-2] + + assert resource.name == "openapi-asyncio-resource" + assert recorded.path == "/api/resources?source=openapi-asyncio" + assert recorded.body == b'{"name": "openapi-asyncio-resource"}' + assert recorded.header("user-agent") == "caller-agent" + assert recorded.header("authorization") == "DPoP integration-access-token" + assert recorded.header("dpop") != "stale-proof" + + +def test_openapi_asyncio_raw_method_returns_native_readable_response( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> tuple[aiohttp.ClientResponse, str]: + client = _client(oauth_environment) + response = await client.api.create_resource_without_preload_content(Resource(name="raw-response")) + assert isinstance(response, aiohttp.ClientResponse) + assert not response.content.at_eof() + value = await response.json() + resource_id = value["id"] + await client.api.delete_resource(resource_id) + await client.close() + return response, resource_id + + response, resource_id = asyncio.run(exercise()) + + assert response.status == 201 + assert resource_id == "1" + + +def test_openapi_asyncio_client_reuses_cached_token(oauth_environment: OAuthTestEnvironment) -> None: + async def exercise() -> None: + client = _client(oauth_environment) + try: + for _ in range(2): + with pytest.raises(ApiException) as error: + await client.api.get_resource("missing") + assert error.value.status == 404 + finally: + await client.close() + + asyncio.run(exercise()) + + assert len(oauth_environment.authorization.state.requests) == 2 + + +def test_openapi_asyncio_client_reacquires_invalidated_token(oauth_environment: OAuthTestEnvironment) -> None: + async def exercise(config: OAuth2Config, store: InvalidatableTokenStore) -> None: + client = _client(oauth_environment, config=config) + try: + resource_id = await client.create_resource("expires") + store.invalidate() + assert await client.get_resource(resource_id) == resource_id + await client.delete_resource(resource_id) + finally: + await client.close() + + store = InvalidatableTokenStore() + config = oauth_environment.config("ec", token_store=store) + asyncio.run(exercise(config, store)) + + assert len(oauth_environment.authorization.state.requests) == 4 + + +def test_openapi_asyncio_client_stops_after_one_token_nonce_retry( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> None: + client = _client(oauth_environment) + try: + await client.create_resource("never-created") + finally: + await client.close() + + oauth_environment.authorization.state.always_challenge_nonce = True + + with pytest.raises(ApiException) as error: + asyncio.run(exercise()) + + assert error.value.status == 400 + assert len(oauth_environment.authorization.state.requests) == 2 + assert oauth_environment.resource.state.requests == [] + + +def test_openapi_asyncio_client_returns_configured_server_errors( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def create(name: str) -> None: + client = _client(oauth_environment) + try: + await client.create_resource(name) + finally: + await client.close() + + oauth_environment.authorization.state.reject_client_assertion = True + with pytest.raises(ApiException) as assertion_error: + asyncio.run(create("rejected")) + assert assertion_error.value.status == 400 + assert json.loads(assertion_error.value.body) == { + "error": "invalid_client", + "error_description": "Client assertion was rejected", + } + + oauth_environment.reset() + oauth_environment.resource.state.insufficient_scope = True + with pytest.raises(ApiException) as scope_error: + asyncio.run(create("forbidden")) + assert scope_error.value.status == 403 + assert json.loads(scope_error.value.body) == { + "error": "insufficient_scope", + "error_description": "requested scope is not permitted", + } + assert scope_error.value.headers["WWW-Authenticate"] == 'DPoP error="insufficient_scope"' + + +def test_openapi_asyncio_client_stops_after_one_resource_nonce_retry( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> None: + client = _client(oauth_environment) + try: + await client.create_resource("never-created") + finally: + await client.close() + + oauth_environment.resource.state.always_challenge_nonce = True + + with pytest.raises(ApiException) as error: + asyncio.run(exercise()) + + assert error.value.status == 401 + assert len(oauth_environment.resource.state.requests) == 2 + + +def test_openapi_asyncio_preserves_generated_session_settings_and_middleware_order( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> tuple[list[str], list[str], int, int, bool]: + middleware_urls: list[str] = [] + traced_urls: list[str] = [] + + async def record_middleware( + request: aiohttp.ClientRequest, + handler: aiohttp.ClientHandlerType, + ) -> aiohttp.ClientResponse: + middleware_urls.append(str(request.url)) + return await handler(request) + + async def record_trace( + session: aiohttp.ClientSession, + context: object, + params: aiohttp.TraceRequestStartParams, + ) -> None: + traced_urls.append(str(params.url)) + + trace_config = aiohttp.TraceConfig() + trace_config.on_request_start.append(record_trace) + timeout = aiohttp.ClientTimeout(total=17) + generated_config = GeneratedConfiguration( + host=oauth_environment.resource.base_url, + ssl_ca_cert=str(oauth_environment.tls_files.certificate), + trace_configs=[trace_config], + tcp_connector_limit_per_host=3, + connection_pool_maxsize=7, + client_session_kwargs={"timeout": timeout, "middlewares": (record_middleware,)}, + ) + api_client = GeneratedApiClient(generated_config) + add_oauth2_layer(api_client, oauth_environment.config("ec")) + oauth_environment.resource.state.always_challenge_nonce = True + try: + with pytest.raises(ApiException): + await ResourcesApi(api_client).create_resource(Resource(name="never-created")) + pool = api_client.rest_client.pool_manager + if pool is None: + raise AssertionError("Generated asyncio pool was not created") + connector = pool.connector + return middleware_urls, traced_urls, connector.limit, connector.limit_per_host, pool.timeout is timeout + finally: + pool = api_client.rest_client.pool_manager + await api_client.close() + pool_closed = pool is not None and pool.closed + assert pool_closed + + middleware_urls, traced_urls, limit, limit_per_host, timeout_preserved = asyncio.run(exercise()) + + resource_url = f"{oauth_environment.resource.base_url}/api/resources" + assert middleware_urls == [resource_url, resource_url] + assert traced_urls == [ + resource_url, + f"{oauth_environment.authorization.base_url}/oauth/token", + f"{oauth_environment.authorization.base_url}/oauth/token", + ] + assert limit == 7 + assert limit_per_host == 3 + assert timeout_preserved + + +def test_openapi_asyncio_retry_client_reuses_oauth2_session_and_cached_token( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> bool: + generated_config = GeneratedConfiguration( + host=oauth_environment.resource.base_url, + ssl_ca_cert=str(oauth_environment.tls_files.certificate), + retries=aiohttp_retry.ExponentialRetry(attempts=2, statuses={500}), + ) + api_client = GeneratedApiClient(generated_config) + add_oauth2_layer(api_client, oauth_environment.config("ec")) + api = ResourcesApi(api_client) + resource = await api.create_resource(Resource(name="retry-resource")) + if not isinstance(resource.id, str): + raise TypeError("Created resource has no ID") + oauth_environment.resource.state.fail_get = True + try: + with pytest.raises(ApiException) as error: + await api.get_resource(resource.id) + assert error.value.status == 500 + return api_client.rest_client.retry_client is not None + finally: + oauth_environment.resource.state.fail_get = False + await api.delete_resource(resource.id) + await api_client.close() + + assert asyncio.run(exercise()) + assert len(oauth_environment.authorization.state.requests) == 2 + assert [request.method for request in oauth_environment.resource.state.requests].count("GET") == 2 diff --git a/tests/integration/test_openapi_httpx.py b/tests/integration/test_openapi_httpx.py new file mode 100644 index 0000000..9a8a8db --- /dev/null +++ b/tests/integration/test_openapi_httpx.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +import asyncio +import json + +import httpx +import pytest +from generated_httpx_test_client import Resource # type: ignore[import-not-found] +from generated_httpx_test_client.exceptions import ApiException # type: ignore[import-not-found] + +from mastercard_oauth2_client import OAuth2Config +from tests.integration.support.environment import OAuthTestEnvironment +from tests.integration.support.lifecycle import run_async_resource_lifecycle +from tests.integration.support.lifecycle_clients.openapi_httpx import OpenApiHttpxLifecycleClient +from tests.integration.support.models import DPoPAlgorithm +from tests.integration.support.stores import InvalidatableTokenStore + +pytestmark = [pytest.mark.generated, pytest.mark.httpx] + + +def _client( + environment: OAuthTestEnvironment, + algorithm: DPoPAlgorithm = "ec", + *, + config: OAuth2Config | None = None, +) -> OpenApiHttpxLifecycleClient: + return OpenApiHttpxLifecycleClient.create( + environment.resource.base_url, + str(environment.tls_files.certificate), + config or environment.config(algorithm), + ) + + +@pytest.mark.parametrize("dpop_algorithm", ["ec", "rsa"]) +def test_openapi_httpx_client_completes_stateful_https_lifecycle( + oauth_environment: OAuthTestEnvironment, + dpop_algorithm: DPoPAlgorithm, +) -> None: + async def exercise() -> str: + client = _client(oauth_environment, dpop_algorithm) + try: + return await run_async_resource_lifecycle(client) + finally: + await client.close() + + resource_id = asyncio.run(exercise()) + + assert resource_id == "1" + assert oauth_environment.resource.state.resources == {} + assert [request.method for request in oauth_environment.authorization.state.requests] == ["POST", "POST"] + assert [request.method for request in oauth_environment.resource.state.requests] == [ + "POST", + "POST", + "GET", + "DELETE", + ] + assert len({request.client_port for request in oauth_environment.authorization.state.requests}) == 1 + assert len({request.client_port for request in oauth_environment.resource.state.requests}) == 1 + + +def test_openapi_httpx_lifecycle_attempts_delete_after_get_failure( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> None: + client = _client(oauth_environment) + try: + await run_async_resource_lifecycle(client) + finally: + await client.close() + + oauth_environment.resource.state.fail_get = True + + with pytest.raises(ApiException) as error: + asyncio.run(exercise()) + + assert error.value.status == 500 + assert oauth_environment.resource.state.resources == {} + assert [request.method for request in oauth_environment.resource.state.requests][-2:] == ["GET", "DELETE"] + + +def test_openapi_httpx_client_preserves_query_json_and_caller_headers( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> Resource: + client = _client(oauth_environment) + client.api.api_client.user_agent = "caller-agent" + resource_id: str | None = None + try: + resource = await client.api.create_resource( + Resource(name="openapi-httpx-resource"), + source="openapi-httpx", + _headers={"Authorization": "Bearer stale", "DPoP": "stale-proof"}, + ) + if not isinstance(resource.id, str): + raise TypeError("Created resource has no ID") + resource_id = resource.id + return resource + finally: + if resource_id is not None: + await client.api.delete_resource(resource_id) + await client.close() + + resource = asyncio.run(exercise()) + recorded = oauth_environment.resource.state.requests[-2] + + assert resource.name == "openapi-httpx-resource" + assert recorded.path == "/api/resources?source=openapi-httpx" + assert recorded.body == b'{"name":"openapi-httpx-resource"}' + assert recorded.header("user-agent") == "caller-agent" + assert recorded.header("authorization") == "DPoP integration-access-token" + assert recorded.header("dpop") != "stale-proof" + + +def test_openapi_httpx_raw_method_returns_native_readable_response( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> tuple[httpx.Response, str]: + client = _client(oauth_environment) + response = await client.api.create_resource_without_preload_content(Resource(name="raw-response")) + assert response.is_stream_consumed + resource_id = response.json()["id"] + await client.api.delete_resource(resource_id) + await client.close() + return response, resource_id + + response, resource_id = asyncio.run(exercise()) + + assert isinstance(response, httpx.Response) + assert response.status_code == 201 + assert response.is_stream_consumed + assert resource_id == "1" + assert response.json() == {"id": "1", "name": "raw-response"} + + +def test_openapi_httpx_client_reuses_cached_token(oauth_environment: OAuthTestEnvironment) -> None: + async def exercise() -> None: + client = _client(oauth_environment) + try: + for _ in range(2): + with pytest.raises(ApiException) as error: + await client.api.get_resource("missing") + assert error.value.status == 404 + finally: + await client.close() + + asyncio.run(exercise()) + + assert len(oauth_environment.authorization.state.requests) == 2 + + +def test_openapi_httpx_client_reacquires_invalidated_token(oauth_environment: OAuthTestEnvironment) -> None: + async def exercise(config: OAuth2Config, store: InvalidatableTokenStore) -> None: + client = _client(oauth_environment, config=config) + try: + resource_id = await client.create_resource("expires") + store.invalidate() + assert await client.get_resource(resource_id) == resource_id + await client.delete_resource(resource_id) + finally: + await client.close() + + store = InvalidatableTokenStore() + config = oauth_environment.config("ec", token_store=store) + asyncio.run(exercise(config, store)) + + assert len(oauth_environment.authorization.state.requests) == 4 + + +def test_openapi_httpx_client_stops_after_one_token_nonce_retry( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> None: + client = _client(oauth_environment) + try: + await client.create_resource("never-created") + finally: + await client.close() + + oauth_environment.authorization.state.always_challenge_nonce = True + + with pytest.raises(ApiException) as error: + asyncio.run(exercise()) + + assert error.value.status == 400 + assert len(oauth_environment.authorization.state.requests) == 2 + assert oauth_environment.resource.state.requests == [] + + +def test_openapi_httpx_client_returns_configured_server_errors( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def create(name: str) -> None: + client = _client(oauth_environment) + try: + await client.create_resource(name) + finally: + await client.close() + + oauth_environment.authorization.state.reject_client_assertion = True + with pytest.raises(ApiException) as assertion_error: + asyncio.run(create("rejected")) + assert assertion_error.value.status == 400 + assert json.loads(assertion_error.value.body) == { + "error": "invalid_client", + "error_description": "Client assertion was rejected", + } + + oauth_environment.reset() + oauth_environment.resource.state.insufficient_scope = True + with pytest.raises(ApiException) as scope_error: + asyncio.run(create("forbidden")) + assert scope_error.value.status == 403 + assert json.loads(scope_error.value.body) == { + "error": "insufficient_scope", + "error_description": "requested scope is not permitted", + } + assert scope_error.value.headers["WWW-Authenticate"] == 'DPoP error="insufficient_scope"' + + +def test_openapi_httpx_client_stops_after_one_resource_nonce_retry( + oauth_environment: OAuthTestEnvironment, +) -> None: + async def exercise() -> None: + client = _client(oauth_environment) + try: + await client.create_resource("never-created") + finally: + await client.close() + + oauth_environment.resource.state.always_challenge_nonce = True + + with pytest.raises(ApiException) as error: + asyncio.run(exercise()) + + assert error.value.status == 401 + assert len(oauth_environment.resource.state.requests) == 2 + + +def test_openapi_httpx_client_close_closes_generated_pool(oauth_environment: OAuthTestEnvironment) -> None: + async def exercise() -> bool: + client = _client(oauth_environment) + pool = client.api_client.rest_client.pool_manager + if pool is None: + raise AssertionError("OAuth2 layer did not install generated HTTPX pool") + await client.close() + return bool(pool.is_closed) + + assert asyncio.run(exercise()) diff --git a/tests/resources/openapi/fake-api.yaml b/tests/resources/openapi/fake-api.yaml new file mode 100644 index 0000000..6544f7e --- /dev/null +++ b/tests/resources/openapi/fake-api.yaml @@ -0,0 +1,69 @@ +openapi: 3.0.3 +info: + title: OAuth2 integration test API + version: 1.0.0 +servers: + - url: https://localhost +paths: + /api/resources: + post: + operationId: createResource + tags: [Resources] + parameters: + - name: source + in: query + required: false + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Resource" + responses: + "201": + description: Resource created + content: + application/json: + schema: + $ref: "#/components/schemas/Resource" + /api/resources/{resource_id}: + parameters: + - name: resource_id + in: path + required: true + schema: + type: string + get: + operationId: getResource + tags: [Resources] + responses: + "200": + description: Resource returned + content: + application/json: + schema: + $ref: "#/components/schemas/Resource" + "404": + description: Resource not found + delete: + operationId: deleteResource + tags: [Resources] + responses: + "204": + description: Resource deleted +components: + schemas: + Resource: + type: object + additionalProperties: false + required: [name] + properties: + id: + type: string + readOnly: true + name: + type: string + minLength: 1 + maxLength: 200 \ No newline at end of file diff --git a/tests/unit/configuration.py b/tests/unit/configuration.py new file mode 100644 index 0000000..3c1934a --- /dev/null +++ b/tests/unit/configuration.py @@ -0,0 +1,17 @@ +from cryptography.hazmat.primitives.asymmetric import ec, rsa + +from mastercard_oauth2_client import KeyPair, OAuth2Config, StaticDPoPKeyProvider, StaticScopeResolver + + +def integration_config() -> OAuth2Config: + client_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + dpop_key = ec.generate_private_key(ec.SECP256R1()) + return OAuth2Config( + client_id="client-id", + key_id="key-id", + token_endpoint="https://auth.example.com/token", + issuer="https://auth.example.com", + client_key=client_key, + scope_resolver=StaticScopeResolver({"read"}), + dpop_key_provider=StaticDPoPKeyProvider(KeyPair(dpop_key, dpop_key.public_key())), + ) \ No newline at end of file diff --git a/tests/unit/test_aiohttp_integration.py b/tests/unit/test_aiohttp_integration.py new file mode 100644 index 0000000..0bf7800 --- /dev/null +++ b/tests/unit/test_aiohttp_integration.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any, cast + +import aiohttp +import pytest +from multidict import CIMultiDict + +from mastercard_oauth2_client.handlers import AsyncOAuth2Handler +from mastercard_oauth2_client.integrations.aiohttp import OAuth2Middleware, _AiohttpAdapter +from mastercard_oauth2_client.models import HttpRequest +from tests.unit.configuration import integration_config + +pytestmark = pytest.mark.aiohttp + + +@dataclass +class FakeResponse: + status: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) + body: bytes = b"response" + reads: int = 0 + releases: int = 0 + + async def read(self) -> bytes: + self.reads += 1 + return self.body + + def release(self) -> None: + self.releases += 1 + + +class FakeSession: + def __init__(self, response: FakeResponse) -> None: + self.response = response + self.calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] + + async def request(self, *args: object, **kwargs: object) -> FakeResponse: + self.calls.append((args, kwargs)) + return self.response + + +@dataclass +class FakeRequest: + method: str + url: str + headers: CIMultiDict[str] + session: FakeSession + body: object = b"" + ssl: object = True + proxy: object = None + proxy_headers: object = None + chunked: bool | None = None + updated_bodies: list[object] = field(default_factory=list) + + async def update_body(self, body: object) -> None: + self.updated_bodies.append(body) + self.body = body + + +def test_aiohttp_middleware_constructs_async_handler() -> None: + middleware = OAuth2Middleware(integration_config()) + + assert isinstance(middleware._handler, AsyncOAuth2Handler) + + +def test_aiohttp_adapter_maps_metadata_and_token_request_options() -> None: + async def exercise() -> None: + token_response = FakeResponse(status=201) + expected_response = cast(aiohttp.ClientResponse, token_response) + session = FakeSession(token_response) + request = FakeRequest( + method="GET", + url="https://api.example.com/resources?limit=1", + headers=CIMultiDict({"Accept": "application/json"}), + session=session, + ssl="resource-ssl", + proxy="https://proxy.example.com", + proxy_headers={"Proxy-Authorization": "proxy-value"}, + ) + + async def resource_handler(resource_request: aiohttp.ClientRequest) -> aiohttp.ClientResponse: + raise AssertionError(f"Unexpected resource request: {resource_request!r}") + + adapter = _AiohttpAdapter(resource_handler) + native_request = cast(aiohttp.ClientRequest, request) + token_request = HttpRequest( + method="POST", + url="https://auth.example.com/oauth/token", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + body="grant_type=client_credentials", + ) + + response = await adapter.send_token_request(native_request, token_request) + + assert response is expected_response + assert adapter.request_method(native_request) == "GET" + assert adapter.request_url(native_request) == "https://api.example.com/resources?limit=1" + assert adapter.request_headers(native_request) is request.headers + assert session.calls == [ + ( + ("POST", "https://auth.example.com/oauth/token"), + { + "headers": token_request.headers, + "data": "grant_type=client_credentials", + "ssl": "resource-ssl", + "proxy": "https://proxy.example.com", + "proxy_headers": {"Proxy-Authorization": "proxy-value"}, + "raise_for_status": False, + "middlewares": (), + }, + ) + ] + + asyncio.run(exercise()) + + +def test_aiohttp_adapter_replaces_headers_and_uses_native_resource_handler() -> None: + async def exercise() -> None: + response = FakeResponse() + expected_response = cast(aiohttp.ClientResponse, response) + request = FakeRequest( + method="POST", + url="https://api.example.com/resources", + headers=CIMultiDict( + (("Authorization", "Bearer stale"), ("Authorization", "Bearer duplicate"), ("X-Correlation-ID", "old")) + ), + session=FakeSession(response), + ) + seen_requests: list[aiohttp.ClientRequest] = [] + + async def resource_handler(resource_request: aiohttp.ClientRequest) -> aiohttp.ClientResponse: + seen_requests.append(resource_request) + return cast(aiohttp.ClientResponse, response) + + adapter = _AiohttpAdapter(resource_handler) + native_request = cast(aiohttp.ClientRequest, request) + result = await adapter.send_resource_request( + native_request, + {"Authorization": "DPoP access-token", "DPoP": "fresh-proof", "X-Correlation-ID": "new"}, + ) + + assert result is expected_response + assert seen_requests == [native_request] + assert list(request.headers.items()) == [ + ("Authorization", "DPoP access-token"), + ("DPoP", "fresh-proof"), + ("X-Correlation-ID", "new"), + ] + + asyncio.run(exercise()) + + +def test_aiohttp_adapter_reads_and_releases_internal_responses() -> None: + async def exercise() -> None: + response = FakeResponse(status=401, headers={"DPoP-Nonce": "nonce"}, body=b"challenge") + + async def resource_handler(resource_request: aiohttp.ClientRequest) -> aiohttp.ClientResponse: + raise AssertionError(f"Unexpected resource request: {resource_request!r}") + + adapter = _AiohttpAdapter(resource_handler) + native_response = cast(aiohttp.ClientResponse, response) + + assert adapter.response_status(native_response) == 401 + assert adapter.response_headers(native_response) == {"DPoP-Nonce": "nonce"} + assert await adapter.response_body(native_response) == b"challenge" + await adapter.close_response(native_response) + assert response.reads == 2 + assert response.releases == 1 + + asyncio.run(exercise()) + + +def test_aiohttp_middleware_buffers_payload_before_delegating() -> None: + async def exercise() -> None: + response = FakeResponse() + expected_response = cast(aiohttp.ClientResponse, response) + request = FakeRequest( + method="POST", + url="https://api.example.com/resources", + headers=CIMultiDict({"Content-Type": "application/octet-stream"}), + session=FakeSession(response), + body=aiohttp.payload.BytesPayload(b"request-body"), + chunked=True, + ) + seen_adapters: list[object] = [] + + class FakeHandler: + async def execute(self, resource_request: object, adapter: object) -> aiohttp.ClientResponse: + assert resource_request is request + seen_adapters.append(adapter) + return cast(aiohttp.ClientResponse, response) + + async def resource_handler(resource_request: aiohttp.ClientRequest) -> aiohttp.ClientResponse: + raise AssertionError(f"Unexpected resource request: {resource_request!r}") + + middleware = OAuth2Middleware.__new__(OAuth2Middleware) + middleware._handler = cast(Any, FakeHandler()) + result = await middleware(cast(aiohttp.ClientRequest, request), resource_handler) + + assert result is expected_response + assert request.updated_bodies == [b"request-body"] + assert request.chunked is None + assert len(seen_adapters) == 1 + assert isinstance(seen_adapters[0], _AiohttpAdapter) + + asyncio.run(exercise()) + + +def test_aiohttp_middleware_leaves_empty_body_unchanged() -> None: + async def exercise() -> None: + response = FakeResponse() + expected_response = cast(aiohttp.ClientResponse, response) + request = FakeRequest( + method="GET", + url="https://api.example.com/resources", + headers=CIMultiDict(), + session=FakeSession(response), + ) + + class FakeHandler: + async def execute(self, resource_request: object, adapter: object) -> aiohttp.ClientResponse: + return cast(aiohttp.ClientResponse, response) + + async def resource_handler(resource_request: aiohttp.ClientRequest) -> aiohttp.ClientResponse: + raise AssertionError(f"Unexpected resource request: {resource_request!r}") + + middleware = OAuth2Middleware.__new__(OAuth2Middleware) + middleware._handler = cast(Any, FakeHandler()) + result = await middleware(cast(aiohttp.ClientRequest, request), resource_handler) + + assert result is expected_response + assert request.updated_bodies == [] + + asyncio.run(exercise()) diff --git a/tests/unit/test_async_handler.py b/tests/unit/test_async_handler.py new file mode 100644 index 0000000..fc2be8b --- /dev/null +++ b/tests/unit/test_async_handler.py @@ -0,0 +1,417 @@ +import asyncio +import base64 +import json +from collections.abc import Iterable, Mapping +from collections.abc import Set as AbstractSet +from dataclasses import dataclass +from datetime import UTC, datetime +from urllib.parse import parse_qs + +import pytest +from cryptography.hazmat.primitives.asymmetric import ec, rsa + +import mastercard_oauth2_client.handlers.async_ as async_handler_module +from mastercard_oauth2_client import ( + AccessToken, + AsyncOAuth2Handler, + HttpRequest, + HttpResponse, + InMemoryTokenStore, + KeyPair, + OAuth2Config, + OAuth2Error, + StaticDPoPKeyProvider, + StaticScopeResolver, + parse_token_response, +) +from mastercard_oauth2_client._internal.jose import jwk_thumbprint + + +@dataclass(frozen=True, slots=True) +class NativeRequest: + method: str + url: str + headers: Mapping[str, str] + + +@dataclass(slots=True) +class NativeResponse: + status: int + headers: Mapping[str, str] + body: bytes | str | None = None + body_reads: int = 0 + closed: bool = False + + +class AsyncScriptedAdapter: + def __init__(self, responses: Iterable[NativeResponse]) -> None: + self._responses = iter(responses) + self.requests: list[HttpRequest] = [] + self.token_requests = 0 + + def request_method(self, request: NativeRequest) -> str: + return request.method + + def request_url(self, request: NativeRequest) -> str: + return request.url + + def request_headers(self, request: NativeRequest) -> Mapping[str, str]: + return request.headers + + async def send_token_request(self, resource_request: NativeRequest, request: HttpRequest) -> NativeResponse: + self.token_requests += 1 + self.requests.append(request) + await asyncio.sleep(0) + return next(self._responses) + + async def send_resource_request(self, request: NativeRequest, headers: Mapping[str, str]) -> NativeResponse: + self.requests.append(HttpRequest(method=request.method, url=request.url, headers=dict(headers))) + await asyncio.sleep(0) + return next(self._responses) + + def response_status(self, response: NativeResponse) -> int: + return response.status + + def response_headers(self, response: NativeResponse) -> Mapping[str, str]: + return response.headers + + async def response_body(self, response: NativeResponse) -> bytes | str | None: + response.body_reads += 1 + await asyncio.sleep(0) + return response.body + + async def close_response(self, response: NativeResponse) -> None: + response.closed = True + await asyncio.sleep(0) + + +class FailingAsyncAdapter(AsyncScriptedAdapter): + def __init__(self) -> None: + super().__init__([]) + + async def send_token_request(self, resource_request: NativeRequest, request: HttpRequest) -> NativeResponse: + raise ConnectionError("network unavailable") + + +def _config(token_store: InMemoryTokenStore | None = None) -> OAuth2Config: + client_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + dpop_private_key = ec.generate_private_key(ec.SECP256R1()) + return OAuth2Config( + client_id="client-id", + token_endpoint="https://auth.example.com/oauth/token", + issuer="https://auth.example.com", + client_key=client_key, + key_id="client-key-id", + scope_resolver=StaticScopeResolver({"payments:read"}), + dpop_key_provider=StaticDPoPKeyProvider( + KeyPair(private_key=dpop_private_key, public_key=dpop_private_key.public_key()) + ), + token_store=token_store or InMemoryTokenStore(), + user_agent="library-agent", + ) + + +def _request(url: str = "https://api.example.com/payments") -> NativeRequest: + return NativeRequest(method="GET", url=url, headers={"Accept": "application/json"}) + + +def _token_response(*, nonce: str | None = None) -> NativeResponse: + headers = {"Content-Type": "application/json"} + if nonce is not None: + headers["DPoP-Nonce"] = nonce + return NativeResponse( + status=200, + headers=headers, + body=json.dumps( + { + "access_token": "access-token", + "token_type": "DPoP", + "expires_in": 900, + "scope": "payments:read", + } + ), + ) + + +def _challenge(nonce: str, *, status: int = 401, header: bool = True) -> NativeResponse: + headers = {"DPoP-Nonce": nonce} + if header: + headers["WWW-Authenticate"] = 'DPoP error="use_dpop_nonce"' + return NativeResponse( + status=status, + headers=headers, + body='{"error":"use_dpop_nonce","secret":"response-secret"}', + ) + + +def _claims(compact_jwt: str) -> dict[str, object]: + payload = compact_jwt.split(".")[1] + return json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))) # type: ignore[no-any-return] + + +def test_async_handler_acquires_token_and_preserves_native_response() -> None: + async def exercise() -> None: + config = _config() + token_response = _token_response() + final_response = NativeResponse(status=200, headers={}, body="resource") + adapter = AsyncScriptedAdapter([token_response, final_response]) + + response = await AsyncOAuth2Handler[NativeRequest, NativeResponse](config).execute(_request(), adapter) + + assert response is final_response + assert token_response.closed + assert token_response.body_reads == 1 + assert not final_response.closed + assert final_response.body_reads == 0 + assert adapter.requests[1].headers["Authorization"] == "DPoP access-token" + + asyncio.run(exercise()) + + +def test_async_handler_closes_successful_token_response_before_parsing(monkeypatch: pytest.MonkeyPatch) -> None: + async def exercise() -> None: + config = _config() + token_response = _token_response() + adapter = AsyncScriptedAdapter([token_response, NativeResponse(status=200, headers={})]) + + def parse_after_close( + parse_config: OAuth2Config, + response: HttpResponse, + requested_scopes: AbstractSet[str], + dpop_key_id: str, + ) -> AccessToken: + assert token_response.closed + return parse_token_response(parse_config, response, requested_scopes, dpop_key_id) + + monkeypatch.setitem(async_handler_module.__dict__, "parse_token_response", parse_after_close) + + await AsyncOAuth2Handler[NativeRequest, NativeResponse](config).execute(_request(), adapter) + + asyncio.run(exercise()) + + +@pytest.mark.parametrize("status", [400, 401]) +def test_async_handler_retries_token_nonce_once(status: int) -> None: + async def exercise() -> None: + first = _challenge("token-nonce", status=status, header=False) + adapter = AsyncScriptedAdapter([first, _token_response(), NativeResponse(status=200, headers={})]) + + await AsyncOAuth2Handler[NativeRequest, NativeResponse](_config()).execute(_request(), adapter) + + assert first.closed + assert first.body_reads == 1 + assert len(adapter.requests) == 3 + assert _claims(adapter.requests[1].headers["DPoP"])["nonce"] == "token-nonce" + assertions = [parse_qs(str(request.body))["client_assertion"][0] for request in adapter.requests[:2]] + assert assertions[0] != assertions[1] + + asyncio.run(exercise()) + + +@pytest.mark.parametrize("status", [400, 401]) +def test_async_handler_retries_resource_nonce_once(status: int) -> None: + async def exercise() -> None: + first = _challenge("resource-nonce", status=status) + final_response = NativeResponse(status=200, headers={}) + adapter = AsyncScriptedAdapter([_token_response(), first, final_response]) + + response = await AsyncOAuth2Handler[NativeRequest, NativeResponse](_config()).execute(_request(), adapter) + + assert response is final_response + assert first.closed + assert first.body_reads == 0 + assert _claims(adapter.requests[2].headers["DPoP"])["nonce"] == "resource-nonce" + + asyncio.run(exercise()) + + +def test_async_handler_retries_body_only_resource_nonce() -> None: + async def exercise() -> None: + first = _challenge("resource-nonce", header=False) + adapter = AsyncScriptedAdapter([_token_response(), first, NativeResponse(status=200, headers={})]) + + await AsyncOAuth2Handler[NativeRequest, NativeResponse](_config()).execute(_request(), adapter) + + assert first.body_reads == 1 + assert first.closed + assert _claims(adapter.requests[2].headers["DPoP"])["nonce"] == "resource-nonce" + + asyncio.run(exercise()) + + +def test_async_handler_shares_token_response_nonce_with_resource_proof() -> None: + async def exercise() -> None: + adapter = AsyncScriptedAdapter( + [_token_response(nonce="shared-nonce"), NativeResponse(status=200, headers={})] + ) + + await AsyncOAuth2Handler[NativeRequest, NativeResponse](_config()).execute(_request(), adapter) + + assert _claims(adapter.requests[1].headers["DPoP"])["nonce"] == "shared-nonce" + + asyncio.run(exercise()) + + +def test_async_handler_stops_after_one_token_nonce_retry() -> None: + async def exercise() -> None: + first = _challenge("first") + second = _challenge("second") + adapter = AsyncScriptedAdapter([first, second]) + + response = await AsyncOAuth2Handler[NativeRequest, NativeResponse](_config()).execute(_request(), adapter) + + assert response is second + assert first.closed + assert not second.closed + assert adapter.token_requests == 2 + assert len(adapter.requests) == 2 + + asyncio.run(exercise()) + + +def test_async_handler_returns_terminal_token_error_open() -> None: + async def exercise() -> None: + token_error = NativeResponse(status=403, headers={}, body="denied") + adapter = AsyncScriptedAdapter([token_error]) + + response = await AsyncOAuth2Handler[NativeRequest, NativeResponse](_config()).execute(_request(), adapter) + + assert response is token_error + assert not response.closed + assert response.body_reads == 0 + assert len(adapter.requests) == 1 + + asyncio.run(exercise()) + + +def test_async_handler_stops_after_one_resource_nonce_retry() -> None: + async def exercise() -> None: + first = _challenge("first") + second = _challenge("second") + adapter = AsyncScriptedAdapter([_token_response(), first, second]) + + response = await AsyncOAuth2Handler[NativeRequest, NativeResponse](_config()).execute(_request(), adapter) + + assert response is second + assert first.closed + assert not second.closed + assert len(adapter.requests) == 3 + + asyncio.run(exercise()) + + +def test_async_handler_reuses_cached_token() -> None: + async def exercise() -> None: + config = _config() + dpop_key = config.dpop_key_provider.get_current_key() + config.token_store.put( + AccessToken( + client_id=config.client_id, + token_value="cached-token", + scopes=frozenset({"payments:read"}), + expires_at=datetime(2100, 1, 1, tzinfo=UTC), + jkt=jwk_thumbprint(dpop_key.key_pair.public_key), + ) + ) + adapter = AsyncScriptedAdapter([NativeResponse(status=200, headers={})]) + + await AsyncOAuth2Handler[NativeRequest, NativeResponse](config).execute(_request(), adapter) + + assert adapter.token_requests == 0 + assert adapter.requests[0].headers["Authorization"] == "DPoP cached-token" + + asyncio.run(exercise()) + + +def test_async_handler_preserves_caller_headers_and_replaces_oauth_headers() -> None: + async def exercise() -> None: + config = _config() + dpop_key = config.dpop_key_provider.get_current_key() + config.token_store.put( + AccessToken( + client_id=config.client_id, + token_value="cached-token", + scopes=frozenset({"payments:read"}), + expires_at=datetime(2100, 1, 1, tzinfo=UTC), + jkt=jwk_thumbprint(dpop_key.key_pair.public_key), + ) + ) + request = NativeRequest( + method="GET", + url="https://api.example.com/payments", + headers={ + "Accept": "application/json", + "User-Agent": "caller-agent", + "Authorization": "Bearer stale", + "DPoP": "stale-proof", + }, + ) + adapter = AsyncScriptedAdapter([NativeResponse(status=200, headers={})]) + + await AsyncOAuth2Handler[NativeRequest, NativeResponse](config).execute(request, adapter) + + headers = adapter.requests[0].headers + assert headers["Accept"] == "application/json" + assert headers["User-Agent"] == "caller-agent" + assert headers["Authorization"] == "DPoP cached-token" + assert headers["DPoP"] != "stale-proof" + + asyncio.run(exercise()) + + +def test_async_handler_preserves_transport_errors() -> None: + async def exercise() -> None: + with pytest.raises(ConnectionError, match="network unavailable"): + await AsyncOAuth2Handler[NativeRequest, NativeResponse](_config()).execute( + _request(), + FailingAsyncAdapter(), + ) + + asyncio.run(exercise()) + + +@pytest.mark.parametrize("resource_url", ["http://api.example.com/payments", "/payments", "not-a-url"]) +def test_async_handler_rejects_non_https_resource_before_transport(resource_url: str) -> None: + async def exercise() -> None: + adapter = AsyncScriptedAdapter([]) + + with pytest.raises(OAuth2Error, match="FAPI 2.0 requires HTTPS for resource server"): + await AsyncOAuth2Handler[NativeRequest, NativeResponse](_config()).execute( + _request(resource_url), + adapter, + ) + + assert adapter.requests == [] + + asyncio.run(exercise()) + + +def test_async_handler_logs_lifecycle_without_sensitive_values(caplog: pytest.LogCaptureFixture) -> None: + async def exercise() -> None: + config = _config() + adapter = AsyncScriptedAdapter( + [_challenge("super-secret-nonce"), _token_response(), NativeResponse(status=200, headers={})] + ) + + with caplog.at_level("DEBUG", logger="mastercard_oauth2_client.handlers.async_"): + await AsyncOAuth2Handler[NativeRequest, NativeResponse](config).execute( + _request("https://api.example.com/payments?account=secret#fragment"), + adapter, + ) + + logs = caplog.text + assert "GET https://api.example.com/payments" in logs + assert "Retrieving DPoP key" in logs + assert "DPoP key selected" in logs + assert "cache miss" in logs + assert "nonce-directed retry" in logs + for sensitive_value in ( + "access-token", + "super-secret-nonce", + "response-secret", + "account=secret", + config.dpop_key_provider.get_current_key().key_id, + ): + assert sensitive_value not in logs + + asyncio.run(exercise()) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..b5e4e2f --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,255 @@ +import platform +from dataclasses import FrozenInstanceError, replace +from importlib.metadata import PackageNotFoundError +from typing import Any, TypedDict, cast + +import pytest +from cryptography.hazmat.primitives.asymmetric import ec, rsa + +import mastercard_oauth2_client.config as config_module +from mastercard_oauth2_client import ( + InMemoryTokenStore, + KeyPair, + OAuth2Config, + OAuth2ConfigError, + OAuth2Error, + SecurityProfile, + StaticDPoPKeyProvider, + StaticScopeResolver, +) + + +class ConfigValues(TypedDict): + client_id: str + token_endpoint: str + issuer: str + client_key: rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey + key_id: str + scope_resolver: StaticScopeResolver + dpop_key_provider: StaticDPoPKeyProvider + + +def minimum_config_values() -> ConfigValues: + dpop_private_key = ec.generate_private_key(ec.SECP256R1()) + return { + "client_id": "client-id", + "token_endpoint": "https://sandbox.api.mastercard.com/oauth/token", + "issuer": "https://sandbox.api.mastercard.com", + "client_key": rsa.generate_private_key(public_exponent=65537, key_size=2048), + "key_id": "key-id", + "scope_resolver": StaticScopeResolver({"payments:read"}), + "dpop_key_provider": StaticDPoPKeyProvider( + KeyPair( + private_key=dpop_private_key, + public_key=dpop_private_key.public_key(), + ) + ), + } + + +def test_oauth2_config_accepts_minimum_valid_fapi2_configuration() -> None: + values = minimum_config_values() + + config = OAuth2Config(**values) + + assert config.client_id == "client-id" + assert config.security_profile is SecurityProfile.FAPI2_PRIVATE_KEY_DPOP + assert config.clock_skew_tolerance == 5 + assert isinstance(config.token_store, InMemoryTokenStore) + assert config.user_agent.startswith("Mastercard-OAuth2-Client/") + assert "(Python/" in config.user_agent + assert isinstance(config.client_key, rsa.RSAPrivateKey) + + +def test_oauth2_config_trims_client_and_key_identifiers() -> None: + values = minimum_config_values() + values["client_id"] = " client-id " + values["key_id"] = " key-id " + + config = OAuth2Config(**values) + + assert config.client_id == "client-id" + assert config.key_id == "key-id" + + +def test_default_user_agent_has_exact_runtime_format(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(config_module, "version", lambda distribution: "1.2.3") + monkeypatch.setattr(platform, "python_version", lambda: "3.13.2") + monkeypatch.setattr(platform, "system", lambda: "TestOS") + monkeypatch.setattr(platform, "release", lambda: "9") + + assert config_module.default_user_agent() == "Mastercard-OAuth2-Client/1.2.3 (Python/3.13.2; TestOS 9)" + + +def test_default_user_agent_uses_unknown_version_when_distribution_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def missing_distribution(distribution: str) -> str: + raise PackageNotFoundError(distribution) + + monkeypatch.setattr(config_module, "version", missing_distribution) + monkeypatch.setattr(platform, "python_version", lambda: "3.13.2") + monkeypatch.setattr(platform, "system", lambda: "TestOS") + monkeypatch.setattr(platform, "release", lambda: "9") + + assert config_module.default_user_agent() == ( + "Mastercard-OAuth2-Client/0.0.0-unknown (Python/3.13.2; TestOS 9)" + ) + + +def test_oauth2_config_uses_distinct_default_token_stores() -> None: + first = OAuth2Config(**minimum_config_values()) + second = OAuth2Config(**minimum_config_values()) + + assert first.token_store is not second.token_store + + +def test_oauth2_config_accepts_replaceable_defaults() -> None: + custom_store = InMemoryTokenStore() + + config = OAuth2Config( + **minimum_config_values(), + token_store=custom_store, + user_agent="customer-agent/1.0", + ) + + assert config.token_store is custom_store + assert config.user_agent == "customer-agent/1.0" + + +def test_oauth2_config_uses_generated_user_agent_for_blank_override() -> None: + config = OAuth2Config(**minimum_config_values(), user_agent=" ") + + assert config.user_agent.startswith("Mastercard-OAuth2-Client/") + + +def test_oauth2_config_is_immutable() -> None: + config = OAuth2Config(**minimum_config_values()) + + with pytest.raises(FrozenInstanceError): + config.client_id = "different-client" # type: ignore[misc] + + +def test_oauth2_config_rejects_missing_client_id() -> None: + values = minimum_config_values() + values["client_id"] = " " + + with pytest.raises(OAuth2ConfigError, match="Client ID is required"): + OAuth2Config(**values) + + +def test_oauth2_config_rejects_missing_token_endpoint() -> None: + config = OAuth2Config(**minimum_config_values()) + + with pytest.raises(OAuth2ConfigError, match="Token endpoint is required"): + replace(config, token_endpoint=" ") + + +@pytest.mark.parametrize("token_endpoint", ["http://auth.example.com/token", "/oauth/token", "not-a-url"]) +def test_oauth2_config_requires_absolute_https_token_endpoint(token_endpoint: str) -> None: + values = minimum_config_values() + values["token_endpoint"] = token_endpoint + + with pytest.raises(OAuth2ConfigError, match="FAPI 2.0 requires HTTPS token endpoint"): + OAuth2Config(**values) + + +def test_oauth2_config_rejects_missing_issuer() -> None: + config = OAuth2Config(**minimum_config_values()) + + with pytest.raises(OAuth2ConfigError, match="Issuer is required"): + replace(config, issuer=" ") + + +def test_oauth2_config_rejects_missing_client_key() -> None: + config = OAuth2Config(**minimum_config_values()) + + with pytest.raises(OAuth2ConfigError, match="Client private key is required"): + replace(config, client_key=None) # type: ignore[arg-type] + + +def test_oauth2_config_rejects_missing_key_id() -> None: + config = OAuth2Config(**minimum_config_values()) + + with pytest.raises(OAuth2ConfigError, match=r"Key ID \(kid\) is required"): + replace(config, key_id=" ") + + +def test_oauth2_config_rejects_missing_scope_resolver() -> None: + config = OAuth2Config(**minimum_config_values()) + + with pytest.raises(OAuth2ConfigError, match="Scope resolver is required"): + replace(config, scope_resolver=None) # type: ignore[arg-type] + + +def test_oauth2_config_rejects_missing_dpop_key_provider() -> None: + config = OAuth2Config(**minimum_config_values()) + + with pytest.raises(OAuth2ConfigError, match="DPoP key provider is required"): + replace(config, dpop_key_provider=None) # type: ignore[arg-type] + + +def test_oauth2_config_rejects_missing_token_store() -> None: + config = OAuth2Config(**minimum_config_values()) + + with pytest.raises(OAuth2ConfigError, match="Token store is required"): + replace(config, token_store=None) # type: ignore[arg-type] + + +def test_oauth2_config_rejects_unsupported_security_profile() -> None: + config = OAuth2Config(**minimum_config_values()) + + with pytest.raises(OAuth2ConfigError, match="Security profile must be FAPI 2.0"): + replace(config, security_profile=object()) # type: ignore[arg-type] + + +def test_oauth2_config_rejects_explicitly_missing_security_profile() -> None: + config = OAuth2Config(**minimum_config_values()) + + with pytest.raises(OAuth2ConfigError, match="Security profile must be FAPI 2.0"): + replace(config, security_profile=None) # type: ignore[arg-type] + + +def test_oauth2_config_rejects_negative_clock_skew() -> None: + config = OAuth2Config(**minimum_config_values()) + + with pytest.raises(OAuth2ConfigError, match="Clock skew tolerance must not be negative"): + replace(config, clock_skew_tolerance=-1) + + +@pytest.mark.parametrize( + ("field_name", "value", "message"), + [ + pytest.param("client_id", None, "Client ID is required", id="client-id-none"), + pytest.param("client_id", 123, "Client ID is required", id="client-id-number"), + pytest.param("token_endpoint", None, "Token endpoint is required", id="token-endpoint-none"), + pytest.param("token_endpoint", 123, "Token endpoint is required", id="token-endpoint-number"), + pytest.param("issuer", None, "Issuer is required", id="issuer-none"), + pytest.param("issuer", 123, "Issuer is required", id="issuer-number"), + pytest.param("key_id", None, r"Key ID \(kid\) is required", id="key-id-none"), + pytest.param("key_id", 123, r"Key ID \(kid\) is required", id="key-id-number"), + pytest.param("user_agent", None, "User agent must be a string", id="user-agent-none"), + pytest.param("user_agent", 123, "User agent must be a string", id="user-agent-number"), + pytest.param("clock_skew_tolerance", None, "Clock skew tolerance must be an integer", id="skew-none"), + pytest.param("clock_skew_tolerance", "5", "Clock skew tolerance must be an integer", id="skew-string"), + pytest.param("clock_skew_tolerance", True, "Clock skew tolerance must be an integer", id="skew-bool"), + ], +) +def test_oauth2_config_rejects_invalid_runtime_types(field_name: str, value: object, message: str) -> None: + config = OAuth2Config(**minimum_config_values()) + + with pytest.raises(OAuth2ConfigError, match=message): + cast(Any, replace)(config, **{field_name: value}) + + +def test_oauth2_config_error_is_an_oauth2_error() -> None: + assert issubclass(OAuth2ConfigError, OAuth2Error) + + +def test_oauth2_config_accepts_ec_client_key() -> None: + values = minimum_config_values() + values["client_key"] = ec.generate_private_key(ec.SECP256R1()) + + config = OAuth2Config(**values) + + assert isinstance(config.client_key, ec.EllipticCurvePrivateKey) diff --git a/tests/unit/test_handler.py b/tests/unit/test_handler.py new file mode 100644 index 0000000..8d69468 --- /dev/null +++ b/tests/unit/test_handler.py @@ -0,0 +1,376 @@ +import base64 +import json +from collections.abc import Iterable, Mapping +from collections.abc import Set as AbstractSet +from dataclasses import dataclass +from datetime import UTC, datetime +from threading import Lock +from urllib.parse import parse_qs + +import pytest +from cryptography.hazmat.primitives.asymmetric import ec, rsa + +import mastercard_oauth2_client.handlers.sync as handler_module +from mastercard_oauth2_client import ( + AccessToken, + AccessTokenFilter, + HttpRequest, + HttpResponse, + InMemoryTokenStore, + KeyPair, + OAuth2Config, + OAuth2Error, + OAuth2Handler, + StaticDPoPKeyProvider, + StaticScopeResolver, + parse_token_response, +) +from mastercard_oauth2_client._internal.jose import jwk_thumbprint + + +def _decode_claims(token: str) -> dict[str, object]: + payload = token.split(".")[1] + return json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))) # type: ignore[no-any-return] + + +@dataclass(frozen=True, slots=True) +class NativeRequest: + method: str + url: str + headers: Mapping[str, str] + body: bytes | str | None = None + + +@dataclass(slots=True) +class NativeResponse: + status: int + headers: Mapping[str, str] + body: bytes | str | None = None + body_reads: int = 0 + closed: bool = False + + +class ScriptedAdapter: + def __init__(self, responses: Iterable[NativeResponse]) -> None: + self._responses = iter(responses) + self.requests: list[HttpRequest] = [] + self.closed_responses: list[NativeResponse] = [] + self.token_requests = 0 + self._response_lock = Lock() + + def request_method(self, request: NativeRequest) -> str: + return request.method + + def request_url(self, request: NativeRequest) -> str: + return request.url + + def request_headers(self, request: NativeRequest) -> Mapping[str, str]: + return request.headers + + def send_token_request(self, resource_request: NativeRequest, request: HttpRequest) -> NativeResponse: + self.token_requests += 1 + self.requests.append(request) + with self._response_lock: + return next(self._responses) + + def send_resource_request(self, request: NativeRequest, headers: Mapping[str, str]) -> NativeResponse: + self.requests.append(HttpRequest(method=request.method, url=request.url, headers=dict(headers), body=request.body)) + with self._response_lock: + return next(self._responses) + + def response_status(self, response: NativeResponse) -> int: + return response.status + + def response_headers(self, response: NativeResponse) -> Mapping[str, str]: + return response.headers + + def response_body(self, response: NativeResponse) -> bytes | str | None: + response.body_reads += 1 + return response.body + + def close_response(self, response: NativeResponse) -> None: + response.closed = True + self.closed_responses.append(response) + + +class FailingAdapter(ScriptedAdapter): + def __init__(self) -> None: + super().__init__([]) + + def send_token_request(self, resource_request: NativeRequest, request: HttpRequest) -> NativeResponse: + raise ConnectionError("network unavailable") + + +def _config(token_store: InMemoryTokenStore | None = None) -> OAuth2Config: + client_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + dpop_private_key = ec.generate_private_key(ec.SECP256R1()) + return OAuth2Config( + client_id="client-id", + token_endpoint="https://auth.example.com/oauth/token?tenant=secret", + issuer="https://auth.example.com", + client_key=client_key, + key_id="client-key-id", + scope_resolver=StaticScopeResolver({"payments:read"}), + dpop_key_provider=StaticDPoPKeyProvider( + KeyPair(private_key=dpop_private_key, public_key=dpop_private_key.public_key()) + ), + token_store=token_store or InMemoryTokenStore(), + user_agent="library-agent", + ) + + +def _resource_request() -> NativeRequest: + return NativeRequest( + method="GET", + url="https://api.example.com/payments?account=secret#fragment", + headers={"Accept": "application/json", "User-Agent": "caller-agent", "Authorization": "Bearer stale"}, + ) + + +def _token_response(*, nonce: str | None = None) -> NativeResponse: + headers = {} if nonce is None else {"DPoP-Nonce": nonce} + return NativeResponse( + status=200, + headers=headers, + body=json.dumps( + { + "access_token": "new-access-token", + "token_type": "DPoP", + "expires_in": 900, + "scope": "payments:read", + } + ), + ) + + +def _nonce_challenge( + nonce: str, + *, + status: int = 401, + body_error: bool = True, + header_error: bool = True, +) -> NativeResponse: + body = json.dumps({"error": "use_dpop_nonce", "secret": "response-secret"}) if body_error else None + headers = {"dpop-nonce": nonce} + if header_error: + headers["WWW-Authenticate"] = 'DPoP error="use_dpop_nonce"' + return NativeResponse( + status=status, + headers=headers, + body=body, + ) + + +def test_handler_reuses_matching_cached_token() -> None: + config = _config() + dpop_key = config.dpop_key_provider.get_current_key() + config.token_store.put( + AccessToken( + client_id=config.client_id, + token_value="cached-token", + scopes=frozenset({"payments:read"}), + expires_at=datetime(2100, 1, 1, tzinfo=UTC), + jkt=jwk_thumbprint(dpop_key.key_pair.public_key), + ) + ) + expected_response = NativeResponse(status=200, headers={}, body="ok") + transport = ScriptedAdapter([expected_response]) + + response = OAuth2Handler[NativeRequest, NativeResponse](config).execute(_resource_request(), transport) + + assert response is expected_response + assert response.status == 200 + assert response.body_reads == 0 + assert len(transport.requests) == 1 + sent = transport.requests[0] + assert sent.url == _resource_request().url + assert sent.headers["Authorization"] == "DPoP cached-token" + assert sent.headers["User-Agent"] == "caller-agent" + assert len(sent.headers["DPoP"].split(".")) == 3 + + +def test_handler_acquires_stores_and_uses_token_on_cache_miss() -> None: + config = _config() + token_response = _token_response() + transport = ScriptedAdapter([token_response, NativeResponse(status=204, headers={})]) + + response = OAuth2Handler[NativeRequest, NativeResponse](config).execute(_resource_request(), transport) + + assert response.status == 204 + assert [request.url for request in transport.requests] == [config.token_endpoint, _resource_request().url] + assert transport.requests[0].method == "POST" + assert transport.requests[1].headers["Authorization"] == "DPoP new-access-token" + assert token_response.closed + dpop_key = config.dpop_key_provider.get_current_key() + cached = config.token_store.get( + AccessTokenFilter( + scopes=frozenset({"payments:read"}), + jkt=jwk_thumbprint(dpop_key.key_pair.public_key), + ) + ) + assert cached is not None + assert cached.token_value == "new-access-token" + + +def test_handler_closes_successful_token_response_before_parsing(monkeypatch: pytest.MonkeyPatch) -> None: + config = _config() + token_response = _token_response() + transport = ScriptedAdapter([token_response, NativeResponse(status=200, headers={})]) + + def parse_after_close( + parse_config: OAuth2Config, + response: HttpResponse, + requested_scopes: AbstractSet[str], + dpop_key_id: str, + ) -> AccessToken: + assert token_response.closed + return parse_token_response(parse_config, response, requested_scopes, dpop_key_id) + + monkeypatch.setitem(handler_module.__dict__, "parse_token_response", parse_after_close) + + OAuth2Handler[NativeRequest, NativeResponse](config).execute(_resource_request(), transport) + + +@pytest.mark.parametrize("challenge_status", [400, 401]) +def test_handler_retries_token_request_once_with_server_nonce(challenge_status: int) -> None: + config = _config() + transport = ScriptedAdapter( + [ + _nonce_challenge("token-nonce", status=challenge_status, header_error=False), + _token_response(), + NativeResponse(status=200, headers={}, body="ok"), + ] + ) + + OAuth2Handler[NativeRequest, NativeResponse](config).execute(_resource_request(), transport) + + assert len(transport.requests) == 3 + first_proof = transport.requests[0].headers["DPoP"] + second_proof = transport.requests[1].headers["DPoP"] + assert first_proof != second_proof + assert "nonce" not in _decode_claims(first_proof) + assert _decode_claims(second_proof)["nonce"] == "token-nonce" + assertions = [parse_qs(str(request.body))["client_assertion"][0] for request in transport.requests[:2]] + assert assertions[0] != assertions[1] + + +@pytest.mark.parametrize("challenge_status", [400, 401]) +def test_handler_retries_resource_request_once_with_server_nonce(challenge_status: int) -> None: + config = _config() + first_challenge = _nonce_challenge("resource-nonce", status=challenge_status, body_error=False) + transport = ScriptedAdapter( + [ + _token_response(), + first_challenge, + NativeResponse(status=200, headers={}, body="ok"), + ] + ) + + response = OAuth2Handler[NativeRequest, NativeResponse](config).execute(_resource_request(), transport) + + assert response.status == 200 + assert len(transport.requests) == 3 + first_proof = transport.requests[1].headers["DPoP"] + second_proof = transport.requests[2].headers["DPoP"] + assert first_proof != second_proof + assert "nonce" not in _decode_claims(first_proof) + assert _decode_claims(second_proof)["nonce"] == "resource-nonce" + assert first_challenge.closed + + +def test_handler_retries_body_only_resource_nonce() -> None: + config = _config() + first_challenge = _nonce_challenge("resource-nonce", header_error=False) + transport = ScriptedAdapter( + [_token_response(), first_challenge, NativeResponse(status=200, headers={}, body="ok")] + ) + + OAuth2Handler[NativeRequest, NativeResponse](config).execute(_resource_request(), transport) + + assert first_challenge.body_reads == 1 + assert first_challenge.closed + assert _decode_claims(transport.requests[2].headers["DPoP"])["nonce"] == "resource-nonce" + + +def test_handler_shares_nonce_from_token_response_with_resource_proof() -> None: + config = _config() + transport = ScriptedAdapter( + [_token_response(nonce="shared-nonce"), NativeResponse(status=200, headers={}, body="ok")] + ) + + OAuth2Handler[NativeRequest, NativeResponse](config).execute(_resource_request(), transport) + + resource_proof = transport.requests[1].headers["DPoP"] + assert _decode_claims(resource_proof)["nonce"] == "shared-nonce" + + +def test_handler_stops_after_one_resource_nonce_retry() -> None: + config = _config() + transport = ScriptedAdapter([_token_response(), _nonce_challenge("first"), _nonce_challenge("second")]) + + response = OAuth2Handler[NativeRequest, NativeResponse](config).execute(_resource_request(), transport) + + assert response.status == 401 + assert len(transport.requests) == 3 + + +def test_handler_returns_second_token_challenge_without_sending_resource_request() -> None: + config = _config() + first_challenge = _nonce_challenge("first") + second_challenge = _nonce_challenge("second") + transport = ScriptedAdapter([first_challenge, second_challenge]) + + response = OAuth2Handler[NativeRequest, NativeResponse](config).execute(_resource_request(), transport) + + assert response is second_challenge + assert response.status == 401 + assert first_challenge.closed + assert not second_challenge.closed + assert len(transport.requests) == 2 + assert all(request.url == config.token_endpoint for request in transport.requests) + + +def test_handler_preserves_transport_errors() -> None: + config = _config() + + with pytest.raises(ConnectionError, match="network unavailable"): + OAuth2Handler[NativeRequest, NativeResponse](config).execute(_resource_request(), FailingAdapter()) + + +@pytest.mark.parametrize("resource_url", ["http://api.example.com/payments", "/payments", "not-a-url"]) +def test_handler_requires_absolute_https_resource_url(resource_url: str) -> None: + config = _config() + transport = ScriptedAdapter([]) + request = NativeRequest(method="GET", url=resource_url, headers={}) + + with pytest.raises(OAuth2Error, match="FAPI 2.0 requires HTTPS for resource server"): + OAuth2Handler[NativeRequest, NativeResponse](config).execute(request, transport) + + assert transport.requests == [] + + +def test_handler_logs_lifecycle_without_sensitive_values(caplog: pytest.LogCaptureFixture) -> None: + config = _config() + transport = ScriptedAdapter( + [_nonce_challenge("super-secret-nonce"), _token_response(), NativeResponse(status=200, headers={})] + ) + + with caplog.at_level("DEBUG", logger="mastercard_oauth2_client.handlers.sync"): + OAuth2Handler[NativeRequest, NativeResponse](config).execute(_resource_request(), transport) + + logs = caplog.text + assert "GET https://api.example.com/payments" in logs + assert "Retrieving DPoP key" in logs + assert "DPoP key selected" in logs + assert "cache miss" in logs + assert "nonce-directed retry" in logs + for sensitive_value in ( + "new-access-token", + "super-secret-nonce", + "response-secret", + "account=secret", + "tenant=secret", + "Bearer stale", + config.dpop_key_provider.get_current_key().key_id, + ): + assert sensitive_value not in logs diff --git a/tests/unit/test_httpx_async_integration.py b/tests/unit/test_httpx_async_integration.py new file mode 100644 index 0000000..020fd13 --- /dev/null +++ b/tests/unit/test_httpx_async_integration.py @@ -0,0 +1,91 @@ +import asyncio + +import httpx +import pytest + +from mastercard_oauth2_client.integrations.httpx import AsyncOAuth2Transport +from tests.unit.configuration import integration_config + +pytestmark = pytest.mark.httpx + + +def test_async_httpx_transport_buffers_streaming_body_for_nonce_retry() -> None: + async def exercise() -> None: + body = b'{"name":"streamed-resource"}' + challenge_responses: list[httpx.Response] = [] + resource_bodies: list[bytes] = [] + + async def dispatch(request: httpx.Request) -> httpx.Response: + if request.url.host == "auth.example.com": + return httpx.Response( + 200, + json={"access_token": "token", "token_type": "DPoP", "expires_in": 900, "scope": "read"}, + request=request, + ) + resource_bodies.append(await request.aread()) + if len(resource_bodies) == 1: + response = httpx.Response( + 401, + headers={"DPoP-Nonce": "resource-nonce", "WWW-Authenticate": 'DPoP error="use_dpop_nonce"'}, + stream=httpx.ByteStream(b'{"error":"use_dpop_nonce"}'), + request=request, + ) + challenge_responses.append(response) + return response + return httpx.Response(201, json={"id": "1"}, request=request) + + transport = AsyncOAuth2Transport(integration_config(), httpx.MockTransport(dispatch)) + async with httpx.AsyncClient(transport=transport) as client: + response = await client.post( + "https://api.example.com/resources", + headers={"Content-Type": "application/json"}, + content=_body_stream(body), + ) + + assert response.status_code == 201 + assert resource_bodies == [body, body] + assert response.history == [] + assert challenge_responses[0].is_stream_consumed + assert challenge_responses[0].is_closed + + asyncio.run(exercise()) + + +def test_async_httpx_transport_allows_concurrent_cache_misses() -> None: + async def exercise() -> None: + token_requests = 0 + resource_requests = 0 + both_tokens_started = asyncio.Event() + + async def dispatch(request: httpx.Request) -> httpx.Response: + nonlocal resource_requests, token_requests + if request.url.host == "auth.example.com": + token_requests += 1 + if token_requests == 2: + both_tokens_started.set() + await both_tokens_started.wait() + return httpx.Response( + 200, + json={"access_token": "token", "token_type": "DPoP", "expires_in": 900, "scope": "read"}, + request=request, + ) + resource_requests += 1 + return httpx.Response(200, json={"ok": True}, request=request) + + transport = AsyncOAuth2Transport(integration_config(), httpx.MockTransport(dispatch)) + async with httpx.AsyncClient(transport=transport) as client: + responses = await asyncio.gather( + client.get("https://api.example.com/first"), + client.get("https://api.example.com/second"), + ) + + assert [response.status_code for response in responses] == [200, 200] + assert token_requests == 2 + assert resource_requests == 2 + + asyncio.run(exercise()) + + +async def _body_stream(body: bytes): # type: ignore[no-untyped-def] + yield body[:10] + yield body[10:] diff --git a/tests/unit/test_httpx_integration.py b/tests/unit/test_httpx_integration.py new file mode 100644 index 0000000..1fe8eef --- /dev/null +++ b/tests/unit/test_httpx_integration.py @@ -0,0 +1,46 @@ +import httpx +import pytest + +from mastercard_oauth2_client.integrations.httpx import OAuth2Transport +from tests.unit.configuration import integration_config + +pytestmark = pytest.mark.httpx + + +def test_httpx_transport_buffers_streaming_body_for_nonce_retry() -> None: + body = b'{"name":"streamed-resource"}' + challenge_responses: list[httpx.Response] = [] + resource_bodies: list[bytes] = [] + + def dispatch(request: httpx.Request) -> httpx.Response: + if request.url.host == "auth.example.com": + return httpx.Response( + 200, + json={"access_token": "token", "token_type": "DPoP", "expires_in": 900, "scope": "read"}, + request=request, + ) + resource_bodies.append(request.read()) + if len(resource_bodies) == 1: + response = httpx.Response( + 401, + headers={"DPoP-Nonce": "resource-nonce", "WWW-Authenticate": 'DPoP error="use_dpop_nonce"'}, + stream=httpx.ByteStream(b'{"error":"use_dpop_nonce"}'), + request=request, + ) + challenge_responses.append(response) + return response + return httpx.Response(201, json={"id": "1"}, request=request) + + transport = OAuth2Transport(integration_config(), httpx.MockTransport(dispatch)) + with httpx.Client(transport=transport) as client: + response = client.post( + "https://api.example.com/resources", + headers={"Content-Type": "application/json"}, + content=iter([body[:10], body[10:]]), + ) + + assert response.status_code == 201 + assert resource_bodies == [body, body] + assert response.history == [] + assert challenge_responses[0].is_stream_consumed + assert challenge_responses[0].is_closed diff --git a/tests/unit/test_integration_support.py b/tests/unit/test_integration_support.py new file mode 100644 index 0000000..f25e579 --- /dev/null +++ b/tests/unit/test_integration_support.py @@ -0,0 +1,24 @@ +import json + +import pytest + +from tests.integration.support.resource_server import _parse_new_resource + + +def test_fake_resource_server_accepts_schema_valid_record() -> None: + assert _parse_new_resource(b'{"name":"valid-resource"}') == {"name": "valid-resource"} + + +@pytest.mark.parametrize( + "payload", + [ + {}, + {"name": ""}, + {"name": "valid", "id": "client-controlled"}, + {"name": "valid", "unexpected": True}, + {"name": 42}, + ], +) +def test_fake_resource_server_rejects_schema_invalid_record(payload: object) -> None: + with pytest.raises(ValueError): + _parse_new_resource(json.dumps(payload).encode()) diff --git a/tests/unit/test_jose_dpop.py b/tests/unit/test_jose_dpop.py new file mode 100644 index 0000000..de5b624 --- /dev/null +++ b/tests/unit/test_jose_dpop.py @@ -0,0 +1,449 @@ +import base64 +import hashlib +import json +from collections.abc import Mapping +from datetime import UTC, datetime +from pathlib import Path +from typing import cast + +import pytest +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa, utils + +from mastercard_oauth2_client import ( + KeyPair, + OAuth2Config, + OAuth2ConfigError, + OAuth2Error, + StaticDPoPKeyProvider, + StaticScopeResolver, + create_client_assertion, + create_resource_dpop_proof, + create_token_dpop_proof, + load_jwk_key_pair, +) +from mastercard_oauth2_client import dpop as dpop_module +from mastercard_oauth2_client._internal import jose as jose_module + +_FIXED_NOW = datetime(2026, 8, 26, 12, 0, tzinfo=UTC) +_FIXED_JTI = "AAECAwQFBgcICQoL" + + +@pytest.fixture +def fixed_proof_values(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(dpop_module, "_utc_now", lambda: _FIXED_NOW) + monkeypatch.setattr(dpop_module, "_random_jti", lambda: _FIXED_JTI) + + +def _base64url_uint(value: int) -> str: + size = max(1, (value.bit_length() + 7) // 8) + return base64.urlsafe_b64encode(value.to_bytes(size, "big")).rstrip(b"=").decode("ascii") + + +def _private_jwk(private_key: rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey) -> dict[str, str]: + if isinstance(private_key, rsa.RSAPrivateKey): + rsa_numbers = private_key.private_numbers() + rsa_public = rsa_numbers.public_numbers + return { + "kty": "RSA", + "n": _base64url_uint(rsa_public.n), + "e": _base64url_uint(rsa_public.e), + "d": _base64url_uint(rsa_numbers.d), + "p": _base64url_uint(rsa_numbers.p), + "q": _base64url_uint(rsa_numbers.q), + "dp": _base64url_uint(rsa_numbers.dmp1), + "dq": _base64url_uint(rsa_numbers.dmq1), + "qi": _base64url_uint(rsa_numbers.iqmp), + } + + ec_numbers = private_key.private_numbers() + ec_public = ec_numbers.public_numbers + return { + "kty": "EC", + "crv": "P-256", + "x": _base64url_uint(ec_public.x), + "y": _base64url_uint(ec_public.y), + "d": _base64url_uint(ec_numbers.private_value), + } + + +def _decode_part(value: str) -> dict[str, object]: + decoded = json.loads(base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))) + return cast(dict[str, object], decoded) + + +def _decode_jwt(token: str) -> tuple[dict[str, object], dict[str, object], bytes, bytes]: + header_part, payload_part, signature_part = token.split(".") + signature = base64.urlsafe_b64decode(signature_part + "=" * (-len(signature_part) % 4)) + return ( + _decode_part(header_part), + _decode_part(payload_part), + f"{header_part}.{payload_part}".encode(), + signature, + ) + + +def _verify_compact_jws( + token: str, + public_key: rsa.RSAPublicKey | ec.EllipticCurvePublicKey, +) -> tuple[dict[str, object], dict[str, object]]: + header, claims, signing_input, signature = _decode_jwt(token) + if isinstance(public_key, rsa.RSAPublicKey): + public_key.verify( + signature, + signing_input, + padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=32), + hashes.SHA256(), + ) + else: + component_size = (public_key.curve.key_size + 7) // 8 + r = int.from_bytes(signature[:component_size], "big") + s = int.from_bytes(signature[component_size:], "big") + public_key.verify(utils.encode_dss_signature(r, s), signing_input, ec.ECDSA(hashes.SHA256())) + return header, claims + + +def _config( + client_key: rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey, + dpop_private_key: rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey | None = None, +) -> OAuth2Config: + dpop_private_key = dpop_private_key or ec.generate_private_key(ec.SECP256R1()) + dpop_pair = KeyPair(private_key=dpop_private_key, public_key=dpop_private_key.public_key()) + return OAuth2Config( + client_id="client-id", + token_endpoint="https://auth.example.com/oauth/token?ignored=yes", + issuer="https://auth.example.com", + client_key=client_key, + key_id="client-key-id", + scope_resolver=StaticScopeResolver({"payments:read"}), + dpop_key_provider=StaticDPoPKeyProvider(dpop_pair), + ) + + +@pytest.mark.parametrize( + "private_key", + [ + pytest.param(rsa.generate_private_key(public_exponent=65537, key_size=2048), id="RSA"), + pytest.param(ec.generate_private_key(ec.SECP256R1()), id="EC"), + ], +) +def test_imports_private_jwk_as_native_key_pair( + private_key: rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey, +) -> None: + loaded = load_jwk_key_pair(_private_jwk(private_key)) + + assert type(loaded.private_key) is type(private_key) + assert loaded.public_key.public_numbers() == private_key.public_key().public_numbers() + + +def test_jwk_loader_accepts_rsa_without_crt_parameters() -> None: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + minimal_jwk = {key: value for key, value in _private_jwk(private_key).items() if key in {"kty", "n", "e", "d"}} + + loaded = load_jwk_key_pair(minimal_jwk) + + assert loaded.private_key.private_numbers() == private_key.private_numbers() + + +@pytest.mark.parametrize( + ("client_key", "algorithm"), + [ + pytest.param(rsa.generate_private_key(public_exponent=65537, key_size=2048), "PS256", id="PS256"), + pytest.param(ec.generate_private_key(ec.SECP256R1()), "ES256", id="ES256"), + ], +) +def test_client_assertion_has_required_claims_and_independent_signature( + client_key: rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey, + algorithm: str, + fixed_proof_values: None, +) -> None: + config = _config(client_key) + + assertion = create_client_assertion(config) + + header, claims = _verify_compact_jws(assertion, client_key.public_key()) + assert header == {"alg": algorithm, "typ": "JWT", "kid": "client-key-id"} + assert claims == { + "iss": "client-id", + "sub": "client-id", + "aud": "https://auth.example.com", + "jti": _FIXED_JTI, + "iat": 1787745600, + "nbf": 1787745595, + "exp": 1787745695, + } + + +def test_token_dpop_proof_contains_public_jwk_and_nonce(fixed_proof_values: None) -> None: + client_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + config = _config(client_key) + dpop_key = config.dpop_key_provider.get_current_key() + + proof = create_token_dpop_proof( + config, + dpop_key.key_id, + nonce="server-nonce", + ) + + header, claims = _verify_compact_jws(proof, dpop_key.key_pair.public_key) + public_jwk = header["jwk"] + assert isinstance(public_jwk, Mapping) + assert "d" not in public_jwk + assert header["alg"] == "ES256" + assert header["typ"] == "dpop+jwt" + assert header["kid"] == dpop_key.key_id + assert claims == { + "jti": _FIXED_JTI, + "htm": "POST", + "htu": "https://auth.example.com/oauth/token", + "iat": 1787745600, + "exp": 1787745695, + "nonce": "server-nonce", + } + + +def test_token_dpop_proof_supports_ps256() -> None: + client_key = ec.generate_private_key(ec.SECP256R1()) + dpop_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + config = _config(client_key, dpop_private_key) + dpop_key = config.dpop_key_provider.get_current_key() + + proof = create_token_dpop_proof(config, dpop_key.key_id) + + header, _ = _verify_compact_jws(proof, dpop_key.key_pair.public_key) + assert header["alg"] == "PS256" + assert header["jwk"] == { + key: value for key, value in _private_jwk(dpop_private_key).items() if key in {"kty", "n", "e"} + } + + +def test_resource_dpop_proof_binds_method_url_and_access_token(fixed_proof_values: None) -> None: + client_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + config = _config(client_key) + dpop_key = config.dpop_key_provider.get_current_key() + + proof = create_resource_dpop_proof( + config, + dpop_key.key_id, + "get", + "https://api.example.com:8443/payments?limit=10#fragment", + "access-token", + ) + + _, claims = _verify_compact_jws(proof, dpop_key.key_pair.public_key) + assert claims["htm"] == "GET" + assert claims["htu"] == "https://api.example.com:8443/payments" + assert claims["ath"] == base64.urlsafe_b64encode(hashlib.sha256(b"access-token").digest()).rstrip(b"=").decode() + + +def test_static_dpop_provider_accepts_key_pair_and_uses_thumbprint_id() -> None: + private_key = ec.generate_private_key(ec.SECP256R1()) + provider = StaticDPoPKeyProvider(KeyPair(private_key=private_key, public_key=private_key.public_key())) + + current_key = provider.get_current_key() + + public_numbers = private_key.public_key().public_numbers() + canonical = json.dumps( + { + "crv": "P-256", + "kty": "EC", + "x": _base64url_uint(public_numbers.x), + "y": _base64url_uint(public_numbers.y), + }, + separators=(",", ":"), + sort_keys=True, + ).encode() + expected_thumbprint = base64.urlsafe_b64encode(hashlib.sha256(canonical).digest()).rstrip(b"=").decode() + assert current_key.key_id == expected_thumbprint + assert provider.get_key(current_key.key_id) is current_key + + +def test_rsa_jwk_thumbprint_matches_known_vector() -> None: + modulus = ( + "wxnY2XfkJDaA_qIYUHMbT_5RXnE1xK2YdDiwRwuo1JaNa_aZhqqw4u1dg9ztvyCpsbL_VL_FaExSSrK6OSmQJYpisUROux" + "C1ep6Vn7IcuzJmmhUX_vaElWFCEAST5LuFdgnBR8wmChVTh4BHDXmmL0NzJVGXnzwcQN1COP26usmi8-HB5Vr0COYqD8TdVcYyw" + "fsuhbiQY0uFyl8HQuIdiNx4TZBut3nv4Ii33n1HwlESTxgkmTnnOIwEVicug7sep4lh-5mXaGMhObIXzz-SZl2hMwRHpWFr8HH_" + "youIUfbSEgSWmJsvw5PA4XY4awWdUnC-9U7tKGsE36VWfqfDJw" + ) + modulus_bytes = base64.urlsafe_b64decode(modulus + "=" * (-len(modulus) % 4)) + public_key = rsa.RSAPublicNumbers(e=65537, n=int.from_bytes(modulus_bytes, "big")).public_key() + + assert jose_module.jwk_thumbprint(public_key) == "-cSeNq9eyhJsLmX6Nxg_qZ7H0heh0tqnFwkEIlHfRkc" + + +def test_jwk_loader_rejects_public_only_key() -> None: + private_key = ec.generate_private_key(ec.SECP256R1()) + public_only_jwk = {key: value for key, value in _private_jwk(private_key).items() if key != "d"} + + with pytest.raises(OAuth2ConfigError, match=r"Missing required EC JWK parameters \(crv, x, y, d\)"): + load_jwk_key_pair(public_only_jwk) + + +def test_jwk_loader_rejects_non_p256_ec_key() -> None: + private_key = ec.generate_private_key(ec.SECP384R1()) + jwk = _private_jwk(private_key) + jwk["crv"] = "P-384" + + with pytest.raises(OAuth2ConfigError, match="Unsupported curve: P-384"): + load_jwk_key_pair(jwk) + + +@pytest.mark.parametrize( + "private_key", + [ + pytest.param(rsa.generate_private_key(public_exponent=65537, key_size=2048), id="RSA"), + pytest.param(ec.generate_private_key(ec.SECP256R1()), id="EC"), + ], +) +def test_jwk_loader_accepts_json_bytes_and_file_path( + private_key: rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey, + tmp_path: Path, +) -> None: + jwk_bytes = json.dumps(_private_jwk(private_key)).encode() + jwk_path = tmp_path / "dpop-key.json" + jwk_path.write_bytes(jwk_bytes) + + from_bytes = load_jwk_key_pair(jwk_bytes) + from_path = load_jwk_key_pair(jwk_path) + + expected_public_numbers = private_key.public_key().public_numbers() + assert from_bytes.public_key.public_numbers() == expected_public_numbers + assert from_path.public_key.public_numbers() == expected_public_numbers + + +def test_jwk_loader_reports_malformed_json() -> None: + with pytest.raises(OAuth2ConfigError, match="Unable to parse JWK JSON"): + load_jwk_key_pair(b"not-json") + + +@pytest.mark.parametrize( + ("value", "message"), + [ + pytest.param({"n": "AQ", "e": "AQAB", "d": "AQ"}, "Missing required JWK parameter: kty", id="missing-kty"), + pytest.param({"kty": "DSA", "d": "AQ"}, "Unsupported key type: DSA", id="unsupported-kty"), + pytest.param( + {"kty": "RSA", "e": "AQAB"}, + r"Missing required RSA JWK parameters \(n, e, d\)", + id="missing-rsa-members", + ), + pytest.param( + {"kty": "EC", "crv": "P-256"}, + r"Missing required EC JWK parameters \(crv, x, y, d\)", + id="missing-ec-members", + ), + ], +) +def test_jwk_loader_rejects_missing_or_unsupported_members(value: dict[str, str], message: str) -> None: + with pytest.raises(OAuth2ConfigError, match=message): + load_jwk_key_pair(value) + + +def test_public_jwk_rejects_unsupported_key_with_clear_message() -> None: + with pytest.raises(OAuth2Error, match="Unsupported public key type: object"): + jose_module.public_jwk(object()) # type: ignore[arg-type] + + +def test_client_assertion_rejects_non_p256_ec_key() -> None: + config = _config(ec.generate_private_key(ec.SECP384R1())) + + with pytest.raises(OAuth2ConfigError, match="Unsupported curve: secp384r1"): + create_client_assertion(config) + + +def test_signing_rejects_unsupported_algorithm() -> None: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + with pytest.raises(OAuth2ConfigError, match="Unsupported algorithm: RS256"): + jose_module._sign_bytes(b"data", private_key, "RS256") # type: ignore[arg-type] + + +def test_jwt_serialization_requires_signature() -> None: + with pytest.raises(OAuth2Error, match="Signature is required"): + jose_module._serialize_jwt("header.payload", b"") + + +def test_sign_jwt_wraps_unexpected_signing_failure(monkeypatch: pytest.MonkeyPatch) -> None: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + def fail_signing(data: bytes, key: object, algorithm: object) -> bytes: + raise RuntimeError("provider failure") + + monkeypatch.setattr(jose_module, "_sign_bytes", fail_signing) + + with pytest.raises(OAuth2Error, match="Unable to sign JWT") as caught: + jose_module.sign_jwt({"typ": "JWT"}, {"sub": "client"}, private_key) + + assert isinstance(caught.value.__cause__, RuntimeError) + + +@pytest.mark.parametrize( + ("private_key", "public_key", "message"), + [ + pytest.param( + rsa.generate_private_key(public_exponent=65537, key_size=2048), + ec.generate_private_key(ec.SECP256R1()).public_key(), + "ES256 requires an EC private key", + id="RSA-private-EC-public", + ), + pytest.param( + ec.generate_private_key(ec.SECP256R1()), + rsa.generate_private_key(public_exponent=65537, key_size=2048).public_key(), + "PS256 requires an RSA private key", + id="EC-private-RSA-public", + ), + ], +) +def test_dpop_proof_rejects_mismatched_key_algorithms_during_signing( + private_key: rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey, + public_key: rsa.RSAPublicKey | ec.EllipticCurvePublicKey, + message: str, +) -> None: + key_pair = KeyPair(private_key=private_key, public_key=public_key) + config = OAuth2Config( + client_id="client-id", + token_endpoint="https://auth.example.com/oauth/token", + issuer="https://auth.example.com", + client_key=rsa.generate_private_key(public_exponent=65537, key_size=2048), + key_id="client-key-id", + scope_resolver=StaticScopeResolver({"payments:read"}), + dpop_key_provider=StaticDPoPKeyProvider(key_pair), + ) + dpop_key = config.dpop_key_provider.get_current_key() + + with pytest.raises(OAuth2ConfigError, match=message): + create_token_dpop_proof(config, dpop_key.key_id) + + +def test_generated_jti_contains_96_random_bits() -> None: + config = _config(rsa.generate_private_key(public_exponent=65537, key_size=2048)) + + assertion = create_client_assertion(config) + _, claims, _, _ = _decode_jwt(assertion) + jti = claims["jti"] + + assert isinstance(jti, str) + assert len(base64.urlsafe_b64decode(jti + "=" * (-len(jti) % 4))) == 12 + + +def test_dpop_proof_rejects_invalid_url() -> None: + config = _config(rsa.generate_private_key(public_exponent=65537, key_size=2048)) + dpop_key = config.dpop_key_provider.get_current_key() + + with pytest.raises(OAuth2Error, match="Invalid URL for DPoP htu claim"): + create_resource_dpop_proof(config, dpop_key.key_id, "GET", "not-a-url", "access-token") + + +def test_dpop_proof_preserves_explicit_default_port(fixed_proof_values: None) -> None: + config = _config(rsa.generate_private_key(public_exponent=65537, key_size=2048)) + dpop_key = config.dpop_key_provider.get_current_key() + + proof = create_resource_dpop_proof( + config, + dpop_key.key_id, + "GET", + "https://api.example.com:443/payments?limit=10#fragment", + "access-token", + ) + + _, claims, _, _ = _decode_jwt(proof) + assert claims["htu"] == "https://api.example.com:443/payments" diff --git a/tests/unit/test_keys.py b/tests/unit/test_keys.py new file mode 100644 index 0000000..f98c951 --- /dev/null +++ b/tests/unit/test_keys.py @@ -0,0 +1,287 @@ +from collections.abc import Callable +from dataclasses import replace +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, rsa +from cryptography.hazmat.primitives.serialization import pkcs12 +from cryptography.x509.oid import NameOID + +from mastercard_oauth2_client import DPoPKey, KeyPair, OAuth2ConfigError +from mastercard_oauth2_client.keys import ( + load_jwk_key_pair, + load_pkcs12_private_key, + load_private_key, + validate_dpop_key, + validate_key, +) +from tests.unit.test_config import minimum_config_values + + +class FailingDPoPKeyProvider: + def get_current_key(self) -> DPoPKey: + raise ConnectionError("key service unavailable") + + def get_key(self, key_id: str) -> DPoPKey: + raise ConnectionError("key service unavailable") + + +class InvalidIdDPoPKeyProvider: + def __init__(self, key_pair: KeyPair) -> None: + self._key = DPoPKey(key_id=" ", key_pair=key_pair) + + def get_current_key(self) -> DPoPKey: + return self._key + + def get_key(self, key_id: str) -> DPoPKey: + return self._key + + +def test_validate_key_accepts_strong_rsa_and_ec_private_and_public_keys() -> None: + rsa_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + ec_private_key = ec.generate_private_key(ec.SECP256R1()) + + validate_key(rsa_private_key) + validate_key(rsa_private_key.public_key()) + validate_key(ec_private_key) + validate_key(ec_private_key.public_key()) + + +def test_validate_key_rejects_weak_rsa_with_clear_message() -> None: + weak_key = rsa.generate_private_key(public_exponent=65537, key_size=1024) + + with pytest.raises( + OAuth2ConfigError, + match="RSA keys must have a minimum length of 2048 bits, but key length was: 1024", + ): + validate_key(weak_key) + + +def test_validate_key_rejects_weak_ec_with_clear_message() -> None: + weak_key = ec.generate_private_key(ec.SECP192R1()) + + with pytest.raises( + OAuth2ConfigError, + match="Elliptic curve keys must have a minimum length of 224 bits, but key length was: 192", + ): + validate_key(weak_key) + + +def test_validate_key_rejects_unsupported_algorithm_with_clear_message() -> None: + with pytest.raises(OAuth2ConfigError, match="Key algorithm must be RSA or EC, but was: object"): + validate_key(object()) + + +@pytest.mark.parametrize("encoding", [serialization.Encoding.PEM, serialization.Encoding.DER]) +def test_load_private_key_supports_pkcs8_rsa(encoding: serialization.Encoding) -> None: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + key_data = private_key.private_bytes( + encoding=encoding, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + loaded = load_private_key(key_data) + + assert isinstance(loaded, rsa.RSAPrivateKey) + assert loaded.private_numbers() == private_key.private_numbers() + + +def test_load_private_key_supports_pkcs1_pem() -> None: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + key_data = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + + assert isinstance(load_private_key(key_data), rsa.RSAPrivateKey) + + +def test_load_private_key_supports_encrypted_pem_with_string_password() -> None: + private_key = ec.generate_private_key(ec.SECP256R1()) + key_data = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.BestAvailableEncryption(b"secret"), + ) + + loaded = load_private_key(key_data, password="secret") + + assert isinstance(loaded, ec.EllipticCurvePrivateKey) + + +def test_load_private_key_accepts_a_file_path(tmp_path: Path) -> None: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + key_path = tmp_path / "key.pem" + key_path.write_bytes( + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + + assert isinstance(load_private_key(key_path), rsa.RSAPrivateKey) + + +def test_load_pkcs12_private_key_supports_string_password() -> None: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "test")]) + certificate = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(private_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.now(UTC)) + .not_valid_after(datetime.now(UTC) + timedelta(days=1)) + .sign(private_key, hashes.SHA256()) + ) + key_data = pkcs12.serialize_key_and_certificates( + name=b"key", + key=private_key, + cert=certificate, + cas=None, + encryption_algorithm=serialization.BestAvailableEncryption(b"secret"), + ) + + loaded = load_pkcs12_private_key(key_data, password="secret") + + assert isinstance(loaded, rsa.RSAPrivateKey) + + +def test_load_pkcs12_private_key_accepts_a_file_path(tmp_path: Path) -> None: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + key_data = pkcs12.serialize_key_and_certificates( + name=b"key", + key=private_key, + cert=None, + cas=None, + encryption_algorithm=serialization.BestAvailableEncryption(b"secret"), + ) + key_path = tmp_path / "key.p12" + key_path.write_bytes(key_data) + + loaded = load_pkcs12_private_key(key_path, password="secret") + + assert isinstance(loaded, rsa.RSAPrivateKey) + assert loaded.private_numbers() == private_key.private_numbers() + + +@pytest.mark.parametrize("loader", [load_private_key, load_pkcs12_private_key, load_jwk_key_pair]) +def test_key_loaders_propagate_missing_file_path(loader: Callable[[Path], object], tmp_path: Path) -> None: + missing_path = tmp_path / "missing.key" + + with pytest.raises(FileNotFoundError, match="missing.key"): + loader(missing_path) + + +def test_load_pkcs12_private_key_wraps_wrong_password() -> None: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + key_data = pkcs12.serialize_key_and_certificates( + name=b"key", + key=private_key, + cert=None, + cas=None, + encryption_algorithm=serialization.BestAvailableEncryption(b"correct"), + ) + + with pytest.raises(OAuth2ConfigError, match="Unable to load PKCS#12 private key") as caught: + load_pkcs12_private_key(key_data, password="wrong") + + assert "wrong" not in str(caught.value) + + +def test_load_private_key_wraps_invalid_data_without_echoing_it() -> None: + invalid_data = b"super-secret-invalid-key-data" + + with pytest.raises(OAuth2ConfigError, match="Unable to load private key") as caught: + load_private_key(invalid_data) + + assert invalid_data.decode() not in str(caught.value) + + +def test_config_rejects_weak_client_key() -> None: + values = minimum_config_values() + values["client_key"] = rsa.generate_private_key(public_exponent=65537, key_size=1024) + + with pytest.raises(OAuth2ConfigError, match="RSA keys must have a minimum length of 2048 bits"): + from mastercard_oauth2_client import OAuth2Config + + OAuth2Config(**values) + + +def test_config_rejects_weak_ec_client_key() -> None: + values = minimum_config_values() + values["client_key"] = ec.generate_private_key(ec.SECP192R1()) + + with pytest.raises(OAuth2ConfigError, match="Elliptic curve keys must have a minimum length of 224 bits"): + from mastercard_oauth2_client import OAuth2Config + + OAuth2Config(**values) + + +def test_config_rejects_weak_ec_dpop_key() -> None: + from mastercard_oauth2_client import OAuth2Config + + weak_private_key = ec.generate_private_key(ec.SECP192R1()) + weak_dpop_key = DPoPKey( + key_id="weak-key", + key_pair=KeyPair(private_key=weak_private_key, public_key=weak_private_key.public_key()), + ) + + class WeakKeyProvider: + def get_current_key(self) -> DPoPKey: + return weak_dpop_key + + def get_key(self, key_id: str) -> DPoPKey: + return weak_dpop_key + + values = minimum_config_values() + values["dpop_key_provider"] = WeakKeyProvider() # type: ignore[typeddict-item] + + with pytest.raises(OAuth2ConfigError, match="Elliptic curve keys must have a minimum length of 224 bits"): + OAuth2Config(**values) + + +def test_config_rejects_public_client_key() -> None: + from mastercard_oauth2_client import OAuth2Config + + config = OAuth2Config(**minimum_config_values()) + + with pytest.raises(OAuth2ConfigError, match="Client key must be an RSA or EC private key"): + replace(config, client_key=config.client_key.public_key()) # type: ignore[arg-type] + + +def test_config_rejects_invalid_dpop_provider_key_id() -> None: + from mastercard_oauth2_client import OAuth2Config + + config = OAuth2Config(**minimum_config_values()) + provider = InvalidIdDPoPKeyProvider(config.dpop_key_provider.get_current_key().key_pair) + + with pytest.raises(OAuth2ConfigError, match="DPoP key provider must return a valid DPoP key ID"): + replace(config, dpop_key_provider=provider) + + +def test_validate_dpop_key_rejects_missing_key_pair() -> None: + private_key = ec.generate_private_key(ec.SECP256R1()) + dpop_key = DPoPKey( + key_id="dpop-key", + key_pair=KeyPair(private_key=private_key, public_key=private_key.public_key()), + ) + + with pytest.raises(OAuth2ConfigError, match="DPoP key provider must return a valid DPoP key"): + validate_dpop_key(replace(dpop_key, key_pair=None)) # type: ignore[arg-type] + + +def test_config_preserves_dpop_key_provider_errors() -> None: + from mastercard_oauth2_client import OAuth2Config + + config = OAuth2Config(**minimum_config_values()) + + with pytest.raises(ConnectionError, match="key service unavailable"): + replace(config, dpop_key_provider=FailingDPoPKeyProvider()) diff --git a/tests/unit/test_openapi_adapter.py b/tests/unit/test_openapi_adapter.py new file mode 100644 index 0000000..9d71374 --- /dev/null +++ b/tests/unit/test_openapi_adapter.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import base64 +import json +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +import pytest +from cryptography.hazmat.primitives.asymmetric import ec, rsa + +from mastercard_oauth2_client import KeyPair, OAuth2Config, OAuth2Error, StaticDPoPKeyProvider, StaticScopeResolver +from mastercard_oauth2_client.integrations.openapi import add_oauth2_layer +from mastercard_oauth2_client.integrations.openapi.urllib3 import _generated_body +from mastercard_oauth2_client.models import HttpRequest + +pytestmark = [pytest.mark.generated, pytest.mark.urllib3] + + +@dataclass +class GeneratedResponse: + status: int + body: bytes + headers: Mapping[str, str] + reason: str = "" + data: bytes | None = None + read_called: bool = False + release_called: bool = False + + @property + def response(self) -> GeneratedResponse: + return self + + def read(self) -> bytes: + self.read_called = True + self.data = self.body + return self.body + + def release_conn(self) -> None: + self.release_called = True + + def getheaders(self) -> Mapping[str, str]: + return self.headers + + def getheader(self, name: str, default: str | None = None) -> str | None: + return next((value for key, value in self.headers.items() if key.casefold() == name.casefold()), default) + + +class ScriptedRestClient: + def __init__(self, responses: list[GeneratedResponse]) -> None: + self.seen_responses = responses + self.responses = iter(self.seen_responses) + self.requests: list[tuple[str, str, dict[str, Any]]] = [] + + def request(self, method: str, url: str, **kwargs: Any) -> GeneratedResponse: + self.requests.append((method, url, kwargs)) + return next(self.responses) + + +class GeneratedApiClient: + def __init__(self, rest_client: ScriptedRestClient) -> None: + self.rest_client = rest_client + + +def _config() -> OAuth2Config: + client_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + dpop_private_key = ec.generate_private_key(ec.SECP256R1()) + return OAuth2Config( + client_id="client-id", + token_endpoint="https://auth.example.com/oauth/token", + issuer="https://auth.example.com", + client_key=client_key, + key_id="client-key-id", + scope_resolver=StaticScopeResolver({"payments:read"}), + dpop_key_provider=StaticDPoPKeyProvider( + KeyPair(private_key=dpop_private_key, public_key=dpop_private_key.public_key()) + ), + ) + + +def _token_response() -> GeneratedResponse: + return GeneratedResponse( + status=200, + headers={"Content-Type": "application/json"}, + body=json.dumps( + { + "access_token": "access-token", + "token_type": "DPoP", + "expires_in": 900, + "scope": "payments:read", + } + ).encode(), + ) + + +def _nonce_challenge(nonce: str, *, status: int) -> GeneratedResponse: + return GeneratedResponse( + status=status, + headers={"DPoP-Nonce": nonce, "WWW-Authenticate": 'DPoP error="use_dpop_nonce"'}, + body=b'{"error":"use_dpop_nonce"}', + ) + + +def _claims(compact_jwt: str) -> dict[str, object]: + payload = compact_jwt.split(".")[1] + return json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))) # type: ignore[no-any-return] + + +def test_generated_client_call_is_authenticated_without_generated_code_changes() -> None: + rest_client = ScriptedRestClient( + [ + _token_response(), + GeneratedResponse(status=200, headers={"Content-Type": "application/json"}, body=b'{"result":"ok"}'), + GeneratedResponse(status=200, headers={"Content-Type": "application/json"}, body=b'{"result":"cached"}'), + ] + ) + api_client = GeneratedApiClient(rest_client) + add_oauth2_layer(api_client, _config()) + + first = api_client.rest_client.request( + "GET", + "https://api.example.com/payments", + query_params=[("account", "123")], + headers={"Accept": "application/json"}, + _preload_content=True, + _request_timeout=(1, 2), + ) + second = api_client.rest_client.request("GET", "https://api.example.com/payments", headers={}) + + assert first.status == 200 + assert first.read() == b'{"result":"ok"}' + assert first.getheader("content-type") == "application/json" + assert second.read() == b'{"result":"cached"}' + assert [url for _, url, _ in rest_client.requests] == [ + "https://auth.example.com/oauth/token", + "https://api.example.com/payments?account=123", + "https://api.example.com/payments", + ] + token_kwargs = rest_client.requests[0][2] + assert token_kwargs["body"] is None + assert dict(token_kwargs["post_params"])["grant_type"] == "client_credentials" + first_resource_kwargs = rest_client.requests[1][2] + assert first_resource_kwargs["headers"]["Authorization"] == "DPoP access-token" + assert "DPoP" in first_resource_kwargs["headers"] + assert first_resource_kwargs["headers"]["Accept"] == "application/json" + assert first_resource_kwargs["_request_timeout"] == (1, 2) + token_response, first_resource_response, second_resource_response = rest_client.seen_responses + assert token_response.read_called and token_response.release_called + assert first_resource_response.read_called and not first_resource_response.release_called + assert second_resource_response.read_called and not second_resource_response.release_called + + +def test_add_oauth2_layer_rejects_duplicate_attachment() -> None: + api_client = GeneratedApiClient(ScriptedRestClient([])) + config = _config() + add_oauth2_layer(api_client, config) + + with pytest.raises(OAuth2Error, match="already attached"): + add_oauth2_layer(api_client, config) + + +def test_generated_client_retries_token_and_resource_nonce_challenges_once() -> None: + rest_client = ScriptedRestClient( + [ + _nonce_challenge("token-nonce", status=400), + _token_response(), + _nonce_challenge("resource-nonce", status=401), + GeneratedResponse(status=200, headers={}, body=b'{"result":"ok"}'), + ] + ) + api_client = GeneratedApiClient(rest_client) + add_oauth2_layer(api_client, _config()) + + response = api_client.rest_client.request("GET", "https://api.example.com/payments", headers={}) + + assert response.status == 200 + assert [request[1] for request in rest_client.requests] == [ + "https://auth.example.com/oauth/token", + "https://auth.example.com/oauth/token", + "https://api.example.com/payments", + "https://api.example.com/payments", + ] + proofs = [request[2]["headers"]["DPoP"] for request in rest_client.requests] + assert proofs[0] != proofs[1] + assert _claims(proofs[1])["nonce"] == "token-nonce" + assert proofs[2] != proofs[3] + assert _claims(proofs[3])["nonce"] == "resource-nonce" + token_challenge, token_response, resource_challenge, final_response = rest_client.seen_responses + assert token_challenge.release_called + assert token_response.read_called and token_response.release_called + assert resource_challenge.release_called + assert not final_response.read_called and not final_response.release_called + + +def test_generated_client_preserves_json_post_body() -> None: + rest_client = ScriptedRestClient( + [ + _token_response(), + GeneratedResponse(status=201, headers={"Content-Type": "application/json"}, body=b'{"id":"123"}'), + ] + ) + api_client = GeneratedApiClient(rest_client) + add_oauth2_layer(api_client, _config()) + + response = api_client.rest_client.request( + "POST", + "https://api.example.com/payments", + headers={"Content-Type": "application/json", "X-Correlation-ID": "correlation-id"}, + body={"amount": 42, "currency": "USD"}, + ) + + assert response.status == 201 + resource_kwargs = rest_client.requests[1][2] + assert resource_kwargs["body"] == {"amount": 42, "currency": "USD"} + assert resource_kwargs["headers"]["X-Correlation-ID"] == "correlation-id" + assert resource_kwargs["headers"]["Authorization"] == "DPoP access-token" + + +def test_generated_client_preserves_unrecognized_body_without_reencoding() -> None: + body = b"raw-generated-body" + rest_client = ScriptedRestClient( + [ + _token_response(), + GeneratedResponse(status=202, headers={}, body=b""), + ] + ) + api_client = GeneratedApiClient(rest_client) + add_oauth2_layer(api_client, _config()) + + response = api_client.rest_client.request( + "POST", + "https://api.example.com/upload", + headers={"Content-Type": "application/octet-stream"}, + body=body, + ) + + assert response.status == 202 + assert rest_client.requests[1][2]["body"] is body + + +def test_generated_token_body_adapter_supports_json_and_raw_fallbacks() -> None: + json_body = HttpRequest( + method="POST", + url="https://auth.example.com/token", + headers={"Content-Type": "application/json"}, + body='{"audience":"payments"}', + ) + raw_body = HttpRequest( + method="POST", + url="https://auth.example.com/token", + headers={"Content-Type": "application/octet-stream"}, + body=b"raw-token-body", + ) + + assert _generated_body(json_body) == ({"audience": "payments"}, None) + assert _generated_body(raw_body) == (b"raw-token-body", None) diff --git a/tests/unit/test_openapi_asyncio_adapter.py b/tests/unit/test_openapi_asyncio_adapter.py new file mode 100644 index 0000000..b17749b --- /dev/null +++ b/tests/unit/test_openapi_asyncio_adapter.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from typing import Any + +import aiohttp +import pytest +from cryptography.hazmat.primitives.asymmetric import ec, rsa + +from mastercard_oauth2_client import ( + KeyPair, + OAuth2Config, + OAuth2Error, + StaticDPoPKeyProvider, + StaticScopeResolver, +) +from mastercard_oauth2_client.integrations.aiohttp import OAuth2Middleware +from mastercard_oauth2_client.integrations.openapi.asyncio import add_oauth2_layer + +pytestmark = [pytest.mark.generated, pytest.mark.aiohttp] + + +@dataclass +class Configuration: + client_session_kwargs: dict[str, Any] | None = None + + +@dataclass +class RestClient: + configuration: Configuration = field(default_factory=Configuration) + pool_manager: aiohttp.ClientSession | None = None + + +class ApiClient: + def __init__(self, rest_client: RestClient | None = None) -> None: + self._rest_client = rest_client or RestClient() + + @property + def rest_client(self) -> RestClient: + return self._rest_client + + +def _config() -> OAuth2Config: + client_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + dpop_key = ec.generate_private_key(ec.SECP256R1()) + return OAuth2Config( + client_id="client-id", + key_id="key-id", + token_endpoint="https://auth.example.com/token", + issuer="https://auth.example.com", + client_key=client_key, + scope_resolver=StaticScopeResolver({"read"}), + dpop_key_provider=StaticDPoPKeyProvider(KeyPair(dpop_key, dpop_key.public_key())), + ) + + +def test_add_oauth2_layer_preserves_session_kwargs_and_existing_middlewares() -> None: + async def existing_middleware( + request: aiohttp.ClientRequest, + handler: aiohttp.ClientHandlerType, + ) -> aiohttp.ClientResponse: + return await handler(request) + + original_kwargs: dict[str, Any] = { + "cookie_jar": "generated-cookie-jar", + "middlewares": (existing_middleware,), + } + api_client = ApiClient(RestClient(Configuration(original_kwargs))) + + add_oauth2_layer(api_client, _config()) + + session_kwargs = api_client.rest_client.configuration.client_session_kwargs + assert session_kwargs is not None + assert session_kwargs is not original_kwargs + assert session_kwargs["cookie_jar"] == "generated-cookie-jar" + middlewares = session_kwargs["middlewares"] + assert isinstance(middlewares, tuple) + assert isinstance(middlewares[0], OAuth2Middleware) + assert middlewares[1:] == (existing_middleware,) + assert original_kwargs["middlewares"] == (existing_middleware,) + + +def test_add_oauth2_layer_rejects_attachment_after_pool_creation() -> None: + async def exercise() -> None: + pool_manager = aiohttp.ClientSession() + api_client = ApiClient(RestClient(pool_manager=pool_manager)) + try: + with pytest.raises(OAuth2Error, match="before the generated asyncio client sends its first request"): + add_oauth2_layer(api_client, _config()) + finally: + await pool_manager.close() + + asyncio.run(exercise()) + + +def test_add_oauth2_layer_rejects_duplicate_attachment_before_pool_creation() -> None: + api_client = ApiClient() + config = _config() + add_oauth2_layer(api_client, config) + + with pytest.raises(OAuth2Error, match="already attached"): + add_oauth2_layer(api_client, config) diff --git a/tests/unit/test_openapi_httpx_adapter.py b/tests/unit/test_openapi_httpx_adapter.py new file mode 100644 index 0000000..838c734 --- /dev/null +++ b/tests/unit/test_openapi_httpx_adapter.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import asyncio +import ssl +from dataclasses import dataclass, field + +import httpx +import pytest +from cryptography.hazmat.primitives.asymmetric import ec, rsa +from generated_httpx_test_client import ( # type: ignore[import-not-found] + ApiClient as GeneratedApiClient, +) +from generated_httpx_test_client import ( # type: ignore[import-not-found] + Configuration as GeneratedConfiguration, +) +from generated_httpx_test_client import ( # type: ignore[import-not-found] + ResourcesApi as GeneratedResourcesApi, +) +from generated_httpx_test_client.exceptions import ApiException # type: ignore[import-not-found] + +import mastercard_oauth2_client.integrations.openapi.httpx as openapi_httpx +from mastercard_oauth2_client import ( + KeyPair, + OAuth2Config, + OAuth2Error, + StaticDPoPKeyProvider, + StaticScopeResolver, +) +from mastercard_oauth2_client.integrations.openapi.httpx import add_oauth2_layer + +pytestmark = [pytest.mark.generated, pytest.mark.httpx] + + +@dataclass +class RestClient: + maxsize: int = 10 + ssl_context: ssl.SSLContext = field(default_factory=ssl.create_default_context) + proxy: str | None = None + proxy_headers: dict[str, str] | None = None + pool_manager: httpx.AsyncClient | None = None + + +@dataclass +class ApiClient: + rest_client: RestClient + + +def _config() -> OAuth2Config: + client_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + dpop_key = ec.generate_private_key(ec.SECP256R1()) + return OAuth2Config( + client_id="client-id", + key_id="key-id", + token_endpoint="https://auth.example.com/token", + issuer="https://auth.example.com", + client_key=client_key, + scope_resolver=StaticScopeResolver({"read"}), + dpop_key_provider=StaticDPoPKeyProvider(KeyPair(dpop_key, dpop_key.public_key())), + ) + + +def test_add_oauth2_layer_installs_authenticated_async_client() -> None: + async def exercise() -> None: + requests: list[httpx.Request] = [] + + async def dispatch(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.host == "auth.example.com": + return httpx.Response( + 200, + json={"access_token": "token", "token_type": "DPoP", "expires_in": 900, "scope": "read"}, + request=request, + ) + return httpx.Response(200, json={"ok": True}, request=request) + + api_client = ApiClient(RestClient()) + add_oauth2_layer(api_client, _config(), httpx.MockTransport(dispatch)) + assert api_client.rest_client.pool_manager is not None + async with api_client.rest_client.pool_manager as client: + response = await client.get("https://api.example.com/resources") + + assert response.json() == {"ok": True} + assert [request.url.host for request in requests] == ["auth.example.com", "api.example.com"] + assert requests[-1].headers["Authorization"] == "DPoP token" + + asyncio.run(exercise()) + + +def test_add_oauth2_layer_rejects_attachment_after_pool_creation() -> None: + pool_manager = httpx.AsyncClient() + api_client = ApiClient(RestClient(pool_manager=pool_manager)) + try: + with pytest.raises(OAuth2Error, match="before the generated HTTPX client sends its first request"): + add_oauth2_layer(api_client, _config()) + finally: + asyncio.run(pool_manager.aclose()) + + +def test_add_oauth2_layer_preserves_generated_transport_settings(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + mock_transport = httpx.MockTransport(lambda request: httpx.Response(200, request=request)) + + def create_transport(**kwargs: object) -> httpx.AsyncBaseTransport: + captured.update(kwargs) + return mock_transport + + monkeypatch.setattr(openapi_httpx.httpx, "AsyncHTTPTransport", create_transport) + ssl_context = ssl.create_default_context() + api_client = ApiClient( + RestClient( + maxsize=7, + ssl_context=ssl_context, + proxy="http://proxy.example.com:8080", + proxy_headers={"Proxy-Authorization": "Basic value"}, + ) + ) + + add_oauth2_layer(api_client, _config()) + assert api_client.rest_client.pool_manager is not None + asyncio.run(api_client.rest_client.pool_manager.aclose()) + + assert captured["verify"] is ssl_context + assert captured["trust_env"] is True + limits = captured["limits"] + assert isinstance(limits, httpx.Limits) + assert limits.max_connections == 7 + proxy = captured["proxy"] + assert isinstance(proxy, httpx.Proxy) + assert str(proxy.url) == "http://proxy.example.com:8080" + assert proxy.headers["Proxy-Authorization"] == "Basic value" + + +def test_generated_request_timeout_reaches_resource_and_token_transports() -> None: + async def exercise() -> None: + requests: list[httpx.Request] = [] + + async def dispatch(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.host == "auth.example.com": + return httpx.Response( + 200, + json={"access_token": "token", "token_type": "DPoP", "expires_in": 900, "scope": "read"}, + request=request, + ) + return httpx.Response(404, json={"error": "not_found"}, request=request) + + generated_config = GeneratedConfiguration(host="https://api.example.com") + api_client = GeneratedApiClient(generated_config) + add_oauth2_layer(api_client, _config(), httpx.MockTransport(dispatch)) + try: + with pytest.raises(ApiException): + await GeneratedResourcesApi(api_client).get_resource("missing", _request_timeout=(1.0, 2.0)) + finally: + await api_client.close() + + assert len(requests) == 2 + for request in requests: + assert request.extensions["timeout"] == { + "connect": 1.0, + "read": 2.0, + "write": None, + "pool": None, + } + + asyncio.run(exercise()) diff --git a/tests/unit/test_orchestration.py b/tests/unit/test_orchestration.py new file mode 100644 index 0000000..58b7fec --- /dev/null +++ b/tests/unit/test_orchestration.py @@ -0,0 +1,12 @@ +import pytest + +from mastercard_oauth2_client._internal.orchestration import has_nonce_challenge_body + + +@pytest.mark.parametrize("body", [None, b"", "not-json", "[]"]) +def test_nonce_challenge_body_ignores_absent_or_invalid_error_objects(body: bytes | str | None) -> None: + assert not has_nonce_challenge_body(401, body) + + +def test_nonce_challenge_body_requires_supported_status() -> None: + assert not has_nonce_challenge_body(200, '{"error":"use_dpop_nonce"}') diff --git a/tests/unit/test_protocols.py b/tests/unit/test_protocols.py new file mode 100644 index 0000000..928bca0 --- /dev/null +++ b/tests/unit/test_protocols.py @@ -0,0 +1,312 @@ +import asyncio +from collections.abc import Mapping +from datetime import UTC, datetime, timedelta + +import pytest +from cryptography.hazmat.primitives.asymmetric import ec + +from mastercard_oauth2_client import ( + AccessToken, + AccessTokenFilter, + AsyncHttpAdapter, + DPoPKeyProvider, + HttpRequest, + InMemoryTokenStore, + KeyPair, + ScopeResolver, + StaticDPoPKeyProvider, + StaticScopeResolver, + SyncHttpAdapter, + TokenStore, +) + + +class FixedClock: + def __init__(self, current: datetime) -> None: + self.current = current + + def __call__(self) -> datetime: + return self.current + + +class NativeAdapter: + def request_method(self, request: tuple[str, str]) -> str: + return request[0] + + def request_url(self, request: tuple[str, str]) -> str: + return request[1] + + def request_headers(self, request: tuple[str, str]) -> dict[str, str]: + return {} + + def send_token_request(self, resource_request: tuple[str, str], request: HttpRequest) -> object: + return object() + + def send_resource_request(self, request: tuple[str, str], headers: Mapping[str, str]) -> object: + return object() + + def response_status(self, response: object) -> int: + return 200 + + def response_headers(self, response: object) -> dict[str, str]: + return {} + + def response_body(self, response: object) -> bytes: + return b"" + + def close_response(self, response: object) -> None: + pass + + +class AsyncNativeAdapter: + def request_method(self, request: tuple[str, str]) -> str: + return request[0] + + def request_url(self, request: tuple[str, str]) -> str: + return request[1] + + def request_headers(self, request: tuple[str, str]) -> Mapping[str, str]: + return {} + + async def send_token_request(self, resource_request: tuple[str, str], request: HttpRequest) -> object: + return object() + + async def send_resource_request(self, request: tuple[str, str], headers: Mapping[str, str]) -> object: + return object() + + def response_status(self, response: object) -> int: + return 200 + + def response_headers(self, response: object) -> Mapping[str, str]: + return {} + + async def response_body(self, response: object) -> bytes: + return b"" + + async def close_response(self, response: object) -> None: + pass + + +def test_static_scope_resolver_satisfies_protocol_and_normalizes_scopes() -> None: + resolver: ScopeResolver = StaticScopeResolver({" payments:read ", "payments:write", "payments:read"}) + + assert resolver.resolve("GET", "https://api.example.com/payments") == frozenset( + {"payments:read", "payments:write"} + ) + assert resolver.all_scopes() == frozenset({"payments:read", "payments:write"}) + + +def test_static_scope_resolver_accepts_any_set_like_collection() -> None: + resolver = StaticScopeResolver({"payments:read": None}.keys()) + + assert resolver.all_scopes() == frozenset({"payments:read"}) + + +def test_static_dpop_key_provider_returns_current_and_named_key() -> None: + private_key = ec.generate_private_key(ec.SECP256R1()) + key_pair = KeyPair(private_key=private_key, public_key=private_key.public_key()) + provider: DPoPKeyProvider = StaticDPoPKeyProvider(key_pair) + dpop_key = provider.get_current_key() + + assert provider.get_current_key() is dpop_key + assert dpop_key.key_id + assert provider.get_key(dpop_key.key_id) is dpop_key + assert provider.get_key("unknown") is dpop_key + + +def test_sync_adapter_preserves_native_resource_types() -> None: + adapter: SyncHttpAdapter[tuple[str, str], object] = NativeAdapter() + request = ("GET", "https://api.example.com") + + response = adapter.send_resource_request(request, {"Authorization": "DPoP token"}) + + assert adapter.request_method(request) == "GET" + assert adapter.request_url(request) == "https://api.example.com" + assert type(response) is object + + +def test_async_adapter_preserves_native_resource_types() -> None: + async def exercise() -> None: + adapter: AsyncHttpAdapter[tuple[str, str], object] = AsyncNativeAdapter() + request = ("GET", "https://api.example.com") + + response = await adapter.send_resource_request(request, {"Authorization": "DPoP token"}) + + assert adapter.request_method(request) == "GET" + assert adapter.request_url(request) == "https://api.example.com" + assert type(response) is object + + asyncio.run(exercise()) + + +def test_in_memory_store_satisfies_protocol_and_normalizes_scope_order() -> None: + now = datetime(2026, 8, 13, tzinfo=UTC) + store: TokenStore = InMemoryTokenStore(now_provider=FixedClock(now)) + token = AccessToken( + client_id="client-id", + token_value="access-token", + scopes=frozenset({"payments:write", "payments:read"}), + expires_at=now + timedelta(minutes=5), + jkt="thumbprint", + ) + + store.put(token) + + assert store.get(AccessTokenFilter(scopes=frozenset({"payments:read", "payments:write"}), jkt="thumbprint")) is token + assert store.get(AccessTokenFilter(scopes=token.scopes)) is token + assert store.get(AccessTokenFilter(scopes=token.scopes, jkt="different")) is None + + +def test_in_memory_store_keeps_bound_token_when_unbound_token_replaces_scope_lookup() -> None: + now = datetime(2026, 8, 13, tzinfo=UTC) + store = InMemoryTokenStore(now_provider=FixedClock(now)) + scopes = frozenset({"payments:read"}) + bound_token = AccessToken("client-id", "bound", scopes, now + timedelta(minutes=5), "thumbprint") + unbound_token = AccessToken("client-id", "unbound", scopes, now + timedelta(minutes=5)) + + store.put(bound_token) + store.put(unbound_token) + + assert store.get(AccessTokenFilter(scopes=scopes, jkt="thumbprint")) is bound_token + assert store.get(AccessTokenFilter(scopes=scopes)) is unbound_token + + +def test_in_memory_store_bound_token_replaces_unbound_scope_lookup() -> None: + now = datetime(2026, 8, 13, tzinfo=UTC) + store = InMemoryTokenStore(now_provider=FixedClock(now)) + scopes = frozenset({"payments:read"}) + unbound_token = AccessToken("client-id", "unbound", scopes, now + timedelta(minutes=5)) + bound_token = AccessToken("client-id", "bound", scopes, now + timedelta(minutes=5), "thumbprint") + + store.put(unbound_token) + store.put(bound_token) + + assert store.get(AccessTokenFilter(scopes=scopes)) is bound_token + assert store.get(AccessTokenFilter(scopes=scopes, jkt="thumbprint")) is bound_token + + +def test_in_memory_store_keeps_different_key_bindings_separate() -> None: + now = datetime(2026, 8, 13, tzinfo=UTC) + store = InMemoryTokenStore(now_provider=FixedClock(now)) + scopes = frozenset({"payments:read"}) + first = AccessToken("client-id", "first", scopes, now + timedelta(minutes=5), "first-jkt") + second = AccessToken("client-id", "second", scopes, now + timedelta(minutes=5), "second-jkt") + + store.put(first) + store.put(second) + + assert store.get(AccessTokenFilter(scopes=scopes, jkt="first-jkt")) is first + assert store.get(AccessTokenFilter(scopes=scopes, jkt="second-jkt")) is second + assert store.get(AccessTokenFilter(scopes=scopes)) is second + + +def test_in_memory_store_replaces_bound_token_with_same_binding_and_scopes() -> None: + now = datetime(2026, 8, 13, tzinfo=UTC) + store = InMemoryTokenStore(now_provider=FixedClock(now)) + scopes = frozenset({"payments:read"}) + first = AccessToken("client-id", "first", scopes, now + timedelta(minutes=5), "thumbprint") + second = AccessToken("client-id", "second", scopes, now + timedelta(minutes=5), "thumbprint") + + store.put(first) + store.put(second) + + assert store.get(AccessTokenFilter(scopes=scopes)) is second + assert store.get(AccessTokenFilter(scopes=scopes, jkt="thumbprint")) is second + + +def test_in_memory_store_replaces_unbound_token_with_same_scopes() -> None: + now = datetime(2026, 8, 13, tzinfo=UTC) + store = InMemoryTokenStore(now_provider=FixedClock(now)) + scopes = frozenset({"payments:read"}) + first = AccessToken("client-id", "first", scopes, now + timedelta(minutes=5)) + second = AccessToken("client-id", "second", scopes, now + timedelta(minutes=5)) + + store.put(first) + store.put(second) + + assert store.get(AccessTokenFilter(scopes=scopes)) is second + + +def test_in_memory_store_supports_empty_scopes() -> None: + now = datetime(2026, 8, 13, tzinfo=UTC) + store = InMemoryTokenStore(now_provider=FixedClock(now)) + token = AccessToken("client-id", "token", frozenset(), now + timedelta(minutes=5), "thumbprint") + + store.put(token) + + assert store.get(AccessTokenFilter(scopes=frozenset())) is token + assert store.get(AccessTokenFilter(scopes=frozenset(), jkt="thumbprint")) is token + + +@pytest.mark.parametrize( + "scopes", + [ + pytest.param(frozenset(), id="empty"), + pytest.param(frozenset({"payments:read"}), id="subset"), + pytest.param(frozenset({"payments:read", "payments:write", "payments:admin"}), id="superset"), + pytest.param(frozenset({"payments:admin"}), id="unrelated"), + ], +) +def test_in_memory_store_rejects_scope_mismatches(scopes: frozenset[str]) -> None: + now = datetime(2026, 8, 13, tzinfo=UTC) + store = InMemoryTokenStore(now_provider=FixedClock(now)) + token_scopes = frozenset({"payments:read", "payments:write"}) + token = AccessToken("client-id", "token", token_scopes, now + timedelta(minutes=5), "thumbprint") + store.put(token) + + assert store.get(AccessTokenFilter(scopes=scopes)) is None + assert store.get(AccessTokenFilter(scopes=scopes, jkt="thumbprint")) is None + + +def test_in_memory_store_rejects_token_inside_early_expiration_threshold() -> None: + now = datetime(2026, 8, 13, tzinfo=UTC) + store = InMemoryTokenStore(now_provider=FixedClock(now)) + token = AccessToken( + client_id="client-id", + token_value="access-token", + scopes=frozenset({"payments:read"}), + expires_at=now + timedelta(seconds=59), + jkt="thumbprint", + ) + store.put(token) + + assert store.get(AccessTokenFilter(scopes=token.scopes, jkt=token.jkt)) is None + + +def test_in_memory_store_accepts_token_at_early_expiration_boundary() -> None: + now = datetime(2026, 8, 13, tzinfo=UTC) + store = InMemoryTokenStore(now_provider=FixedClock(now)) + token = AccessToken( + client_id="client-id", + token_value="access-token", + scopes=frozenset({"payments:read"}), + expires_at=now + timedelta(seconds=60), + ) + store.put(token) + + assert store.get(AccessTokenFilter(scopes=token.scopes)) is token + + +def test_in_memory_store_removes_expired_entries_during_put() -> None: + now = datetime(2026, 8, 13, tzinfo=UTC) + clock = FixedClock(now) + store = InMemoryTokenStore(now_provider=clock) + expired = AccessToken( + client_id="client-id", + token_value="expired", + scopes=frozenset({"old"}), + expires_at=now + timedelta(seconds=61), + ) + store.put(expired) + clock.current = now + timedelta(minutes=2) + + store.put( + AccessToken( + client_id="client-id", + token_value="current", + scopes=frozenset({"new"}), + expires_at=clock.current + timedelta(minutes=5), + ) + ) + + assert store.get(AccessTokenFilter(scopes=expired.scopes)) is None diff --git a/tests/unit/test_token.py b/tests/unit/test_token.py new file mode 100644 index 0000000..ac8a462 --- /dev/null +++ b/tests/unit/test_token.py @@ -0,0 +1,201 @@ +import json +from datetime import UTC, datetime, timedelta +from urllib.parse import parse_qs + +import pytest +from cryptography.hazmat.primitives.asymmetric import ec, rsa + +from mastercard_oauth2_client import ( + HttpResponse, + KeyPair, + OAuth2Config, + OAuth2Error, + StaticDPoPKeyProvider, + StaticScopeResolver, + build_token_request, + parse_token_response, +) +from mastercard_oauth2_client import token as token_module +from mastercard_oauth2_client._internal.jose import jwk_thumbprint + +_FIXED_NOW = datetime(2026, 8, 28, 12, 0, tzinfo=UTC) + + +def _config() -> OAuth2Config: + client_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + dpop_private_key = ec.generate_private_key(ec.SECP256R1()) + return OAuth2Config( + client_id="client id", + token_endpoint="https://auth.example.com/oauth/token?tenant=test", + issuer="https://auth.example.com", + client_key=client_key, + key_id="client-key-id", + scope_resolver=StaticScopeResolver({"payments:read"}), + dpop_key_provider=StaticDPoPKeyProvider( + KeyPair(private_key=dpop_private_key, public_key=dpop_private_key.public_key()) + ), + user_agent="test-agent", + ) + + +def _response(payload: object, status: int = 200) -> HttpResponse: + return HttpResponse(status=status, headers={"Content-Type": "application/json"}, body=json.dumps(payload)) + + +def test_build_token_request_contains_client_credentials_assertion_and_dpop_proof() -> None: + config = _config() + dpop_key = config.dpop_key_provider.get_current_key() + + request = build_token_request( + config, + {"payments:write", "payments:read"}, + dpop_key.key_id, + nonce="server-nonce", + ) + + assert request.method == "POST" + assert request.url == config.token_endpoint + assert request.headers == { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "DPoP": request.headers["DPoP"], + "User-Agent": "test-agent", + } + assert len(request.headers["DPoP"].split(".")) == 3 + assert isinstance(request.body, str) + form = parse_qs(request.body, keep_blank_values=True) + assert form == { + "grant_type": ["client_credentials"], + "client_id": ["client id"], + "scope": ["payments:read payments:write"], + "client_assertion_type": ["urn:ietf:params:oauth:client-assertion-type:jwt-bearer"], + "client_assertion": [form["client_assertion"][0]], + } + assert len(form["client_assertion"][0].split(".")) == 3 + + +def test_build_token_request_omits_empty_scope() -> None: + config = _config() + dpop_key_id = config.dpop_key_provider.get_current_key().key_id + + request = build_token_request(config, set(), dpop_key_id) + + assert isinstance(request.body, str) + assert "scope" not in parse_qs(request.body, keep_blank_values=True) + + +def test_parse_token_response_returns_cache_ready_dpop_token(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(token_module, "_utc_now", lambda: _FIXED_NOW) + config = _config() + dpop_key = config.dpop_key_provider.get_current_key() + + token = parse_token_response( + config, + _response( + { + "access_token": "access-token", + "token_type": "dPoP", + "expires_in": 900, + "scope": "payments:read payments:write", + } + ), + {"payments:read", "payments:write", "payments:admin"}, + dpop_key.key_id, + ) + + assert token.client_id == config.client_id + assert token.token_value == "access-token" + assert token.scopes == frozenset({"payments:read", "payments:write"}) + assert token.expires_at == _FIXED_NOW + timedelta(seconds=900) + assert token.jkt == jwk_thumbprint(dpop_key.key_pair.public_key) + + +def test_parse_token_response_accepts_fractional_expiry(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(token_module, "_utc_now", lambda: _FIXED_NOW) + config = _config() + dpop_key_id = config.dpop_key_provider.get_current_key().key_id + + token = parse_token_response( + config, + _response({"access_token": "access-token", "token_type": "DPoP", "expires_in": 900.5}), + {"payments:read"}, + dpop_key_id, + ) + + assert token.expires_at == _FIXED_NOW + timedelta(seconds=900.5) + + +def test_parse_token_response_uses_requested_scopes_when_scope_is_omitted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(token_module, "_utc_now", lambda: _FIXED_NOW) + config = _config() + dpop_key_id = config.dpop_key_provider.get_current_key().key_id + + token = parse_token_response( + config, + _response({"access_token": "access-token", "token_type": "DPoP", "expires_in": 900}), + {"payments:read"}, + dpop_key_id, + ) + + assert token.scopes == frozenset({"payments:read"}) + + +@pytest.mark.parametrize( + ("payload", "message"), + [ + ( + {"token_type": "DPoP", "expires_in": 900}, + "Missing value in access token response: access_token", + ), + ( + {"access_token": "token", "expires_in": 900}, + "Missing value in access token response: token_type", + ), + ( + {"access_token": "token", "token_type": "DPoP"}, + "Missing value in access token response: expires_in", + ), + ( + {"access_token": "token", "token_type": "Bearer", "expires_in": 900}, + "Expected DPoP token type but received: Bearer", + ), + ], +) +def test_parse_token_response_requires_dpop_token_fields(payload: object, message: str) -> None: + config = _config() + dpop_key_id = config.dpop_key_provider.get_current_key().key_id + + with pytest.raises(OAuth2Error, match=message): + parse_token_response(config, _response(payload), set(), dpop_key_id) + + +@pytest.mark.parametrize("expires_in", [0, -1, True, "900", None]) +def test_parse_token_response_requires_positive_numeric_expiry(expires_in: object) -> None: + config = _config() + dpop_key_id = config.dpop_key_provider.get_current_key().key_id + payload = {"access_token": "token", "token_type": "DPoP", "expires_in": expires_in} + + with pytest.raises(OAuth2Error, match="FAPI 2.0 requires valid expires_in field"): + parse_token_response(config, _response(payload), set(), dpop_key_id) + + +def test_parse_token_response_rejects_unsuccessful_or_malformed_response() -> None: + config = _config() + dpop_key_id = config.dpop_key_provider.get_current_key().key_id + + with pytest.raises(OAuth2Error, match="Token request failed with HTTP 400"): + parse_token_response(config, _response({"error": "invalid_client"}, status=400), set(), dpop_key_id) + + with pytest.raises(OAuth2Error, match="Failed to parse JSON access token response"): + parse_token_response(config, HttpResponse(status=200, headers={}, body="not-json"), set(), dpop_key_id) + + +@pytest.mark.parametrize("body", [None, "", " ", b""]) +def test_parse_token_response_rejects_empty_body(body: bytes | str | None) -> None: + config = _config() + dpop_key_id = config.dpop_key_provider.get_current_key().key_id + + with pytest.raises(OAuth2Error, match="Empty access token response"): + parse_token_response(config, HttpResponse(status=200, headers={}, body=body), set(), dpop_key_id) From 96ad4ef51573c3c09a08a1e91aecd0e482bff06e Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Sun, 13 Sep 2026 04:14:56 +0530 Subject: [PATCH 02/36] Drain urllib3 responses before connection reuse --- .../integrations/openapi/urllib3.py | 14 +++++++++---- tests/unit/test_openapi_adapter.py | 20 ++++++++++++------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/mastercard_oauth2_client/integrations/openapi/urllib3.py b/src/mastercard_oauth2_client/integrations/openapi/urllib3.py index 0e8d841..d0a1738 100644 --- a/src/mastercard_oauth2_client/integrations/openapi/urllib3.py +++ b/src/mastercard_oauth2_client/integrations/openapi/urllib3.py @@ -16,12 +16,16 @@ class _RawResponse(Protocol): + def drain_conn(self) -> None: ... + def release_conn(self) -> None: ... class _GeneratedResponse(Protocol): status: int - response: _RawResponse + + @property + def response(self) -> _RawResponse: ... def read(self) -> bytes | str | None: ... @@ -32,11 +36,12 @@ def getheaders(self) -> Mapping[str, str]: ... class _RestClient(Protocol): - request: GeneratedRequest + def request(self, method: str, url: str, **kwargs: Any) -> _GeneratedResponse: ... class _ApiClient(Protocol): - rest_client: _RestClient + @property + def rest_client(self) -> _RestClient: ... @dataclass(frozen=True, slots=True) @@ -112,6 +117,7 @@ def response_body(self, response: _GeneratedResponse) -> bytes | str | None: return response.read() def close_response(self, response: _GeneratedResponse) -> None: + response.response.drain_conn() response.response.release_conn() @@ -139,7 +145,7 @@ def authenticated_request(method: str, url: str, **kwargs: Any) -> _GeneratedRes ) setattr(authenticated_request, _OAUTH2_ATTACHED, True) - api_client.rest_client.request = authenticated_request + api_client.rest_client.request = authenticated_request # type: ignore[method-assign] def _url_with_query(url: str, query_params: object) -> str: diff --git a/tests/unit/test_openapi_adapter.py b/tests/unit/test_openapi_adapter.py index 9d71374..82c3213 100644 --- a/tests/unit/test_openapi_adapter.py +++ b/tests/unit/test_openapi_adapter.py @@ -25,6 +25,7 @@ class GeneratedResponse: reason: str = "" data: bytes | None = None read_called: bool = False + drain_called: bool = False release_called: bool = False @property @@ -36,6 +37,9 @@ def read(self) -> bytes: self.data = self.body return self.body + def drain_conn(self) -> None: + self.drain_called = True + def release_conn(self) -> None: self.release_called = True @@ -145,9 +149,11 @@ def test_generated_client_call_is_authenticated_without_generated_code_changes() assert first_resource_kwargs["headers"]["Accept"] == "application/json" assert first_resource_kwargs["_request_timeout"] == (1, 2) token_response, first_resource_response, second_resource_response = rest_client.seen_responses - assert token_response.read_called and token_response.release_called - assert first_resource_response.read_called and not first_resource_response.release_called - assert second_resource_response.read_called and not second_resource_response.release_called + assert token_response.read_called and token_response.drain_called and token_response.release_called + assert first_resource_response.read_called and not first_resource_response.drain_called + assert not first_resource_response.release_called + assert second_resource_response.read_called and not second_resource_response.drain_called + assert not second_resource_response.release_called def test_add_oauth2_layer_rejects_duplicate_attachment() -> None: @@ -186,10 +192,10 @@ def test_generated_client_retries_token_and_resource_nonce_challenges_once() -> assert proofs[2] != proofs[3] assert _claims(proofs[3])["nonce"] == "resource-nonce" token_challenge, token_response, resource_challenge, final_response = rest_client.seen_responses - assert token_challenge.release_called - assert token_response.read_called and token_response.release_called - assert resource_challenge.release_called - assert not final_response.read_called and not final_response.release_called + assert token_challenge.drain_called and token_challenge.release_called + assert token_response.read_called and token_response.drain_called and token_response.release_called + assert resource_challenge.drain_called and resource_challenge.release_called + assert not final_response.read_called and not final_response.drain_called and not final_response.release_called def test_generated_client_preserves_json_post_body() -> None: From bc0defac55df65f5c61c920b1c462471defdec62 Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Tue, 22 Sep 2026 11:53:28 +0530 Subject: [PATCH 03/36] add Requests integration and fix aiohttp token request settings --- .github/workflows/ci-requests.yml | 44 +++ README.md | 26 ++ pyproject.toml | 6 + scripts/check_wheel.py | 3 +- .../integrations/aiohttp.py | 3 - .../integrations/requests.py | 102 ++++++ .../support/authorization_server.py | 6 + .../support/lifecycle_clients/requests.py | 32 ++ tests/integration/support/models.py | 2 + tests/integration/support/resource_server.py | 10 + tests/integration/support/tls.py | 4 +- tests/integration/test_requests.py | 300 ++++++++++++++++++ tests/unit/test_aiohttp_integration.py | 3 - tests/unit/test_requests_integration.py | 169 ++++++++++ 14 files changed, 702 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/ci-requests.yml create mode 100644 src/mastercard_oauth2_client/integrations/requests.py create mode 100644 tests/integration/support/lifecycle_clients/requests.py create mode 100644 tests/integration/test_requests.py create mode 100644 tests/unit/test_requests_integration.py diff --git a/.github/workflows/ci-requests.yml b/.github/workflows/ci-requests.yml new file mode 100644 index 0000000..f835d65 --- /dev/null +++ b/.github/workflows/ci-requests.yml @@ -0,0 +1,44 @@ +name: Requests + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + tests: + name: Requests ${{ matrix.label }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - label: minimum + requests: 'requests==2.33.0' + - label: latest + requests: requests + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev,generator]" "${{ matrix.requests }}" + python -m pip check + + - name: Run Requests tests + run: python scripts/run_tests.py -q -m requests \ No newline at end of file diff --git a/README.md b/README.md index c3135e7..b0da50b 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,12 @@ For aiohttp: python -m pip install "mastercard-oauth2-client[aiohttp]" ``` +For Requests: + +```bash +python -m pip install "mastercard-oauth2-client[requests]" +``` + ### Configuration `OAuth2Config` contains the client credentials, token endpoint, issuer, scopes @@ -105,9 +111,23 @@ Each integration provides the same OAuth2 and DPoP behavior. | HTTP client | Mode | Install | |---|---|---| +| Requests | Sync | `mastercard-oauth2-client[requests]` | | HTTPX | Sync and async | `mastercard-oauth2-client[httpx]` | | aiohttp | Async | `mastercard-oauth2-client[aiohttp]` | +#### Requests + +Install `mastercard-oauth2-client[requests]`, then use `OAuth2Session` like a +normal `requests.Session`: + +```python +from mastercard_oauth2_client.integrations.requests import OAuth2Session + +with OAuth2Session(oauth2_config) as client: + response = client.get("https://api.mastercard.com/resources", timeout=30) + response.raise_for_status() +``` + #### HTTPX Install `mastercard-oauth2-client[httpx]`. @@ -259,6 +279,12 @@ The library supports RSA and EC keys: The token endpoint must be an absolute HTTPS URL. Configuration errors raise `OAuth2ConfigError` with the invalid setting. +### Requests: streaming uploads + +`OAuth2Session` supports bodies prepared as bytes or strings, including normal +JSON, form and multipart requests. Direct streaming bodies such as open files, +generators and pipes are rejected before transmission. + ### Logging Use Python's standard logging configuration to enable messages from diff --git a/pyproject.toml b/pyproject.toml index f43d300..bb43d62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,8 +22,10 @@ dev = [ "pydantic>=2.11", "pytest>=8.4", "python-dateutil>=2.8.2", + "requests>=2.33,<3", "ruff>=0.12", "twine>=6.1", + "types-requests>=2.33", "typing-extensions>=4.14", "urllib3>=2.1,<3", "httpx>=0.28,<1", @@ -34,6 +36,9 @@ httpx = [ aiohttp = [ "aiohttp>=3.13,<4", ] +requests = [ + "requests>=2.33,<3", +] generator = [ "openapi-generator-cli[jdk4py]==7.23.0", ] @@ -49,6 +54,7 @@ markers = [ "aiohttp: aiohttp direct and generated-client integration tests", "generated: OpenAPI-generated client tests", "httpx: HTTPX direct and generated-client integration tests", + "requests: Requests direct integration tests", "urllib3: generated urllib3 client tests", ] diff --git a/scripts/check_wheel.py b/scripts/check_wheel.py index 51ca0e8..2d9324f 100644 --- a/scripts/check_wheel.py +++ b/scripts/check_wheel.py @@ -17,9 +17,10 @@ def main() -> None: raise SystemExit("No wheel found in dist. Run 'python -m build' first.") _check_contents(wheel) - _check_install(wheel, "core", absent=("httpx", "aiohttp")) + _check_install(wheel, "core", absent=("httpx", "aiohttp", "requests")) _check_install(wheel, "httpx", imports=("mastercard_oauth2_client.integrations.httpx",)) _check_install(wheel, "aiohttp", imports=("mastercard_oauth2_client.integrations.aiohttp",)) + _check_install(wheel, "requests", imports=("mastercard_oauth2_client.integrations.requests",)) def _check_contents(wheel: Path) -> None: diff --git a/src/mastercard_oauth2_client/integrations/aiohttp.py b/src/mastercard_oauth2_client/integrations/aiohttp.py index 5c1ba4e..1d48903 100644 --- a/src/mastercard_oauth2_client/integrations/aiohttp.py +++ b/src/mastercard_oauth2_client/integrations/aiohttp.py @@ -34,9 +34,6 @@ async def send_token_request( request.url, headers=request.headers, data=request.body, - ssl=resource_request.ssl, - proxy=resource_request.proxy, - proxy_headers=resource_request.proxy_headers, raise_for_status=False, middlewares=(), ) diff --git a/src/mastercard_oauth2_client/integrations/requests.py b/src/mastercard_oauth2_client/integrations/requests.py new file mode 100644 index 0000000..74b7218 --- /dev/null +++ b/src/mastercard_oauth2_client/integrations/requests.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import requests + +from ..config import OAuth2Config +from ..handlers import OAuth2Handler +from ..models import HttpRequest + + +class _RequestsAdapter: + """Map native Requests messages into synchronous handler operations.""" + + def __init__( + self, + session: requests.Session, + token_session: requests.Session, + resource_settings: Mapping[str, Any], + ) -> None: + self._session = session + self._token_session = token_session + self._resource_settings = dict(resource_settings) + + def request_method(self, request: requests.PreparedRequest) -> str: + return request.method or "" + + def request_url(self, request: requests.PreparedRequest) -> str: + return request.url or "" + + def request_headers(self, request: requests.PreparedRequest) -> Mapping[str, str]: + return request.headers + + def send_token_request( + self, + resource_request: requests.PreparedRequest, + request: HttpRequest, + ) -> requests.Response: + self._sync_token_session() + return self._token_session.request( + method=request.method, + url=request.url, + headers=dict(request.headers), + data=request.body, + timeout=self._resource_settings.get("timeout"), + ) + + def _sync_token_session(self) -> None: + self._token_session.adapters = self._session.adapters + self._token_session.cert = self._session.cert + self._token_session.max_redirects = self._session.max_redirects + self._token_session.proxies = dict(self._session.proxies) + self._token_session.trust_env = self._session.trust_env + self._token_session.verify = self._session.verify + + def send_resource_request( + self, + request: requests.PreparedRequest, + headers: Mapping[str, str], + ) -> requests.Response: + request.headers.clear() + request.headers.update(headers) + return requests.Session.send(self._session, request, **self._resource_settings) + + def response_status(self, response: requests.Response) -> int: + return response.status_code + + def response_headers(self, response: requests.Response) -> Mapping[str, str]: + return response.headers + + def response_body(self, response: requests.Response) -> bytes: + return response.content + + def close_response(self, response: requests.Response) -> None: + response.close() + + +class OAuth2Session(requests.Session): + """A Requests session that authenticates resource calls with OAuth2 and DPoP.""" + + def __init__(self, config: OAuth2Config) -> None: + super().__init__() + # Use a separate Requests session without OAuth resource handling. + self._token_session = requests.Session() + self._handler = OAuth2Handler[requests.PreparedRequest, requests.Response](config) + + def send(self, request: requests.PreparedRequest, **kwargs: Any) -> requests.Response: + """Authenticate and send one prepared resource request.""" + + _reject_streaming_body(request) + adapter = _RequestsAdapter(self, self._token_session, kwargs) + return self._handler.execute(request, adapter) + + +def _reject_streaming_body(request: requests.PreparedRequest) -> None: + # The server may ask us to send the request again with a new DPoP proof. + # Requests cannot safely resend a streaming body, so reject it before sending. + if request.body is not None and not isinstance(request.body, str | bytes | bytearray): + raise requests.exceptions.UnrewindableBodyError( + "OAuth2Session does not support streaming request bodies" + ) \ No newline at end of file diff --git a/tests/integration/support/authorization_server.py b/tests/integration/support/authorization_server.py index d6cab3d..2239587 100644 --- a/tests/integration/support/authorization_server.py +++ b/tests/integration/support/authorization_server.py @@ -2,6 +2,7 @@ import json import ssl +import sys from collections.abc import Mapping from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from threading import Thread @@ -22,6 +23,11 @@ class _AuthorizationHttpServer(ThreadingHTTPServer): daemon_threads = True state: AuthorizationState + def handle_error(self, request: object, client_address: object) -> None: + if isinstance(sys.exception(), (BrokenPipeError, ConnectionResetError)): + return + super().handle_error(request, client_address) + class _AuthorizationHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" diff --git a/tests/integration/support/lifecycle_clients/requests.py b/tests/integration/support/lifecycle_clients/requests.py new file mode 100644 index 0000000..c70640a --- /dev/null +++ b/tests/integration/support/lifecycle_clients/requests.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from requests import Session + +from ..lifecycle import require_resource_id + + +@dataclass(slots=True) +class RequestsLifecycleClient: + """Run the shared lifecycle through a native Requests session.""" + + client: Session + base_url: str + + def create_resource(self, name: str) -> str: + response = self.client.post(f"{self.base_url}/api/resources", json={"name": name}) + response.raise_for_status() + return require_resource_id(response.json()) + + def get_resource(self, resource_id: str) -> str: + response = self.client.get( + f"{self.base_url}/api/resources/{resource_id}", + headers={"Accept": "application/json"}, + ) + response.raise_for_status() + return require_resource_id(response.json()) + + def delete_resource(self, resource_id: str) -> None: + response = self.client.delete(f"{self.base_url}/api/resources/{resource_id}") + response.raise_for_status() \ No newline at end of file diff --git a/tests/integration/support/models.py b/tests/integration/support/models.py index 74016f5..e63bb5a 100644 --- a/tests/integration/support/models.py +++ b/tests/integration/support/models.py @@ -58,6 +58,7 @@ class ResourceState: insufficient_scope: bool = False always_challenge_nonce: bool = False fail_get: bool = False + redirect_location: str | None = None def reset(self) -> None: self.requests.clear() @@ -66,6 +67,7 @@ def reset(self) -> None: self.insufficient_scope = False self.always_challenge_nonce = False self.fail_get = False + self.redirect_location = None type DPoPAlgorithm = Literal["ec", "rsa"] diff --git a/tests/integration/support/resource_server.py b/tests/integration/support/resource_server.py index fc46e82..77ade14 100644 --- a/tests/integration/support/resource_server.py +++ b/tests/integration/support/resource_server.py @@ -2,6 +2,7 @@ import json import ssl +import sys from collections.abc import Mapping from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -15,6 +16,7 @@ RESOURCE_COLLECTION_PATH = "/api/resources" PAYLOAD_ECHO_PATH = "/api/echo" +REDIRECT_PATH = "/api/redirect" _RESOURCE_ITEM_PREFIX = f"{RESOURCE_COLLECTION_PATH}/" @@ -22,6 +24,11 @@ class _ResourceHttpServer(ThreadingHTTPServer): daemon_threads = True state: ResourceState + def handle_error(self, request: object, client_address: object) -> None: + if isinstance(sys.exception(), (BrokenPipeError, ConnectionResetError)): + return + super().handle_error(request, client_address) + class _ResourceHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" @@ -105,6 +112,9 @@ def _validate_request(self, state: ResourceState, request: RecordedRequest) -> N def _handle_resource(self, state: ResourceState, request: RecordedRequest) -> None: path = urlsplit(request.path).path + if path == REDIRECT_PATH and state.redirect_location is not None: + self._send_empty(307, {"Location": state.redirect_location, "Set-Cookie": "redirected=true; Path=/"}) + return if path == PAYLOAD_ECHO_PATH: self._handle_echo(state, request) return diff --git a/tests/integration/support/tls.py b/tests/integration/support/tls.py index 5785374..cddaa2c 100644 --- a/tests/integration/support/tls.py +++ b/tests/integration/support/tls.py @@ -32,7 +32,9 @@ def create_tls_files(directory: Path) -> TlsFiles: .not_valid_before(now - timedelta(minutes=1)) .not_valid_after(now + timedelta(days=1)) .add_extension( - x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), + x509.SubjectAlternativeName( + [x509.IPAddress(ipaddress.ip_address("127.0.0.1")), x509.DNSName("localhost")] + ), critical=False, ) .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) diff --git a/tests/integration/test_requests.py b/tests/integration/test_requests.py new file mode 100644 index 0000000..9318f4b --- /dev/null +++ b/tests/integration/test_requests.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +import io +from urllib.parse import parse_qs + +import pytest +import requests + +from mastercard_oauth2_client import OAuth2Config +from mastercard_oauth2_client.integrations.requests import OAuth2Session +from tests.integration.support.environment import OAuthTestEnvironment +from tests.integration.support.lifecycle import run_resource_lifecycle +from tests.integration.support.lifecycle_clients.requests import RequestsLifecycleClient +from tests.integration.support.models import DPoPAlgorithm +from tests.integration.support.resource_server import PAYLOAD_ECHO_PATH, REDIRECT_PATH +from tests.integration.support.stores import InvalidatableTokenStore + +pytestmark = pytest.mark.requests + + +def _client( + environment: OAuthTestEnvironment, + algorithm: DPoPAlgorithm = "ec", + *, + config: OAuth2Config | None = None, +) -> OAuth2Session: + session = OAuth2Session(config or environment.config(algorithm)) + session.trust_env = False + session.verify = str(environment.tls_files.certificate) + return session + + +@pytest.mark.parametrize("dpop_algorithm", ["ec", "rsa"]) +def test_requests_client_completes_stateful_https_lifecycle( + oauth_environment: OAuthTestEnvironment, + dpop_algorithm: DPoPAlgorithm, +) -> None: + with _client(oauth_environment, dpop_algorithm) as client: + resource_id = run_resource_lifecycle(RequestsLifecycleClient(client, oauth_environment.resource.base_url)) + + assert resource_id == "1" + assert oauth_environment.resource.state.resources == {} + assert [request.method for request in oauth_environment.authorization.state.requests] == ["POST", "POST"] + assert [request.method for request in oauth_environment.resource.state.requests] == [ + "POST", + "POST", + "GET", + "DELETE", + ] + assert len({request.client_port for request in oauth_environment.authorization.state.requests}) == 1 + assert len({request.client_port for request in oauth_environment.resource.state.requests}) == 1 + + +def test_requests_lifecycle_attempts_delete_after_get_failure(oauth_environment: OAuthTestEnvironment) -> None: + oauth_environment.resource.state.fail_get = True + + with _client(oauth_environment) as client, pytest.raises(requests.HTTPError) as error: + run_resource_lifecycle(RequestsLifecycleClient(client, oauth_environment.resource.base_url)) + + assert error.value.response is not None + assert error.value.response.status_code == 500 + assert oauth_environment.resource.state.resources == {} + assert [request.method for request in oauth_environment.resource.state.requests][-2:] == ["GET", "DELETE"] + + +def test_requests_client_preserves_query_json_and_caller_headers(oauth_environment: OAuthTestEnvironment) -> None: + with _client(oauth_environment) as client: + response = client.post( + f"{oauth_environment.resource.base_url}/api/resources", + params={"source": "requests"}, + json={"name": "requests-resource"}, + headers={ + "Accept": "application/json", + "User-Agent": "caller-agent", + "Authorization": "Bearer stale", + "DPoP": "stale-proof", + }, + ) + + recorded = oauth_environment.resource.state.requests[-1] + assert response.status_code == 201 + assert response.json() == {"id": "1", "name": "requests-resource"} + assert recorded.path == "/api/resources?source=requests" + assert recorded.body == b'{"name": "requests-resource"}' + assert recorded.header("user-agent") == "caller-agent" + assert recorded.header("authorization") == "DPoP integration-access-token" + assert recorded.header("dpop") != "stale-proof" + assert response.request.headers["Authorization"] == "DPoP integration-access-token" + + +def test_requests_client_preserves_form_body_for_nonce_retry(oauth_environment: OAuthTestEnvironment) -> None: + with _client(oauth_environment) as client: + response = client.post( + f"{oauth_environment.resource.base_url}{PAYLOAD_ECHO_PATH}", + data={"name": "form resource", "tag": ["first", "second"]}, + ) + + assert response.status_code == 200 + recorded = oauth_environment.resource.state.requests + assert len(recorded) == 2 + assert [parse_qs(request.body.decode()) for request in recorded] == [ + {"name": ["form resource"], "tag": ["first", "second"]}, + {"name": ["form resource"], "tag": ["first", "second"]}, + ] + + +def test_requests_client_preserves_multipart_file_for_nonce_retry(oauth_environment: OAuthTestEnvironment) -> None: + body = b"multipart-file-content" + with _client(oauth_environment) as client: + response = client.post( + f"{oauth_environment.resource.base_url}{PAYLOAD_ECHO_PATH}", + data={"description": "multipart resource"}, + files={"document": ("resource.txt", io.BytesIO(body), "text/plain")}, + ) + + assert response.status_code == 200 + recorded = oauth_environment.resource.state.requests + assert len(recorded) == 2 + assert recorded[0].header("content-type") == recorded[1].header("content-type") + assert [request.body for request in recorded] == [recorded[0].body, recorded[0].body] + assert body in recorded[0].body + + +def test_requests_client_rejects_generator_before_transmission(oauth_environment: OAuthTestEnvironment) -> None: + with _client(oauth_environment) as client, pytest.raises( + requests.exceptions.UnrewindableBodyError, + match="streaming request bodies", + ): + client.post( + f"{oauth_environment.resource.base_url}{PAYLOAD_ECHO_PATH}", + data=iter((b"first", b"second")), + ) + + assert oauth_environment.authorization.state.requests == [] + assert oauth_environment.resource.state.requests == [] + + +def test_requests_client_reuses_cached_token(oauth_environment: OAuthTestEnvironment) -> None: + with _client(oauth_environment) as client: + first = client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + second = client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + + assert first.status_code == 404 + assert second.status_code == 404 + assert len(oauth_environment.authorization.state.requests) == 2 + + +def test_requests_client_reacquires_invalidated_token(oauth_environment: OAuthTestEnvironment) -> None: + store = InvalidatableTokenStore() + config = oauth_environment.config("ec", token_store=store) + with _client(oauth_environment, config=config) as client: + client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + store.invalidate() + client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + + assert len(oauth_environment.authorization.state.requests) == 4 + + +def test_requests_client_stops_after_one_token_nonce_retry(oauth_environment: OAuthTestEnvironment) -> None: + oauth_environment.authorization.state.always_challenge_nonce = True + + with _client(oauth_environment) as client: + response = client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + + assert response.status_code == 400 + assert response.history == [] + assert len(oauth_environment.authorization.state.requests) == 2 + assert oauth_environment.resource.state.requests == [] + + +def test_requests_client_returns_native_token_error(oauth_environment: OAuthTestEnvironment) -> None: + oauth_environment.authorization.state.reject_client_assertion = True + + with _client(oauth_environment) as client: + response = client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + + assert isinstance(response, requests.Response) + assert response.status_code == 400 + assert response.json() == { + "error": "invalid_client", + "error_description": "Client assertion was rejected", + } + assert oauth_environment.resource.state.requests == [] + + +def test_requests_client_returns_resource_server_error(oauth_environment: OAuthTestEnvironment) -> None: + oauth_environment.resource.state.insufficient_scope = True + + with _client(oauth_environment) as client: + response = client.post( + f"{oauth_environment.resource.base_url}/api/resources", + json={"name": "forbidden"}, + ) + + assert response.status_code == 403 + assert response.json() == { + "error": "insufficient_scope", + "error_description": "requested scope is not permitted", + } + assert response.headers["WWW-Authenticate"] == 'DPoP error="insufficient_scope"' + + +def test_requests_client_stops_after_one_resource_nonce_retry(oauth_environment: OAuthTestEnvironment) -> None: + oauth_environment.resource.state.always_challenge_nonce = True + + with _client(oauth_environment) as client: + response = client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + + assert response.status_code == 401 + assert len(oauth_environment.resource.state.requests) == 2 + + +def test_requests_streaming_response_remains_caller_owned(oauth_environment: OAuthTestEnvironment) -> None: + with _client(oauth_environment) as client: + response = client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + stream=True, + ) + try: + assert response.status_code == 404 + assert not response._content_consumed + assert response.content == b'{"error":"not_found"}' + finally: + response.close() + + +def test_requests_client_preserves_resource_response_hooks(oauth_environment: OAuthTestEnvironment) -> None: + statuses: list[int] = [] + with _client(oauth_environment) as client: + response = client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + hooks={"response": lambda native, **kwargs: statuses.append(native.status_code)}, + ) + + assert response.status_code == 404 + assert statuses == [401, 404] + + +def test_requests_client_reauthenticates_cross_origin_redirect_without_leaking_cookies( + oauth_environment: OAuthTestEnvironment, +) -> None: + redirected_url = oauth_environment.resource.base_url.replace("127.0.0.1", "localhost") + oauth_environment.resource.state.redirect_location = f"{redirected_url}/api/resources/missing" + + with _client(oauth_environment) as client: + response = client.get( + f"{oauth_environment.resource.base_url}{REDIRECT_PATH}", + headers={"Accept": "application/json"}, + ) + + assert response.status_code == 404 + assert [entry.status_code for entry in response.history] == [307] + assert [request.path for request in oauth_environment.resource.state.requests] == [ + REDIRECT_PATH, + REDIRECT_PATH, + "/api/resources/missing", + ] + redirected_request = oauth_environment.resource.state.requests[-1] + assert redirected_request.header("host").startswith("localhost:") + assert redirected_request.header("cookie") is None + + +def test_requests_client_preserves_cookies_on_same_origin_redirect( + oauth_environment: OAuthTestEnvironment, +) -> None: + oauth_environment.resource.state.redirect_location = ( + f"{oauth_environment.resource.base_url}/api/resources/missing" + ) + + with _client(oauth_environment) as client: + response = client.get( + f"{oauth_environment.resource.base_url}{REDIRECT_PATH}", + headers={"Accept": "application/json"}, + ) + + assert response.status_code == 404 + assert oauth_environment.resource.state.requests[-1].header("cookie") == "redirected=true" \ No newline at end of file diff --git a/tests/unit/test_aiohttp_integration.py b/tests/unit/test_aiohttp_integration.py index 0bf7800..1c4c1a0 100644 --- a/tests/unit/test_aiohttp_integration.py +++ b/tests/unit/test_aiohttp_integration.py @@ -106,9 +106,6 @@ async def resource_handler(resource_request: aiohttp.ClientRequest) -> aiohttp.C { "headers": token_request.headers, "data": "grant_type=client_credentials", - "ssl": "resource-ssl", - "proxy": "https://proxy.example.com", - "proxy_headers": {"Proxy-Authorization": "proxy-value"}, "raise_for_status": False, "middlewares": (), }, diff --git a/tests/unit/test_requests_integration.py b/tests/unit/test_requests_integration.py new file mode 100644 index 0000000..3c752fb --- /dev/null +++ b/tests/unit/test_requests_integration.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import json +from io import BytesIO +from typing import Any + +import pytest +import requests + +from mastercard_oauth2_client.integrations.requests import OAuth2Session +from tests.unit.configuration import integration_config + +pytestmark = pytest.mark.requests + + +class StubRequestsAdapter(requests.adapters.BaseAdapter): + def __init__(self, responses: list[requests.Response] | None = None) -> None: + self.calls: list[tuple[requests.PreparedRequest, dict[str, Any]]] = [] + self._responses = iter(responses) if responses is not None else None + self.closed = False + + def send(self, request: requests.PreparedRequest, **kwargs: Any) -> requests.Response: + self.calls.append((request, kwargs)) + if self._responses is not None: + response = next(self._responses) + response.request = request + return response + response = requests.Response() + response.status_code = 200 + response.request = request + if request.url == "https://auth.example.com/token": + response.headers["Content-Type"] = "application/json" + response._content = json.dumps( + { + "access_token": "token", + "token_type": "DPoP", + "expires_in": 900, + "scope": "read", + } + ).encode() + else: + response._content = b'{"ok":true}' + return response + + def close(self) -> None: + self.closed = True + + +def test_requests_session_authenticates_native_request_with_same_adapter() -> None: + adapter = StubRequestsAdapter() + session = OAuth2Session(integration_config()) + session.trust_env = False + session.verify = "token-ca.pem" + session.cert = "token-client.pem" + session.mount("https://", adapter) + prepared = session.prepare_request( + requests.Request( + "POST", + "https://api.example.com/resources", + headers={"Authorization": "Bearer stale", "DPoP": "stale-proof"}, + json={"name": "resource"}, + ) + ) + + response = session.send(prepared, timeout=3, verify=False, cert="client.pem", proxies={}) + + assert response.request is prepared + assert [request.url for request, _ in adapter.calls] == [ + "https://auth.example.com/token", + "https://api.example.com/resources", + ] + token_request, token_settings = adapter.calls[0] + resource_request, resource_settings = adapter.calls[1] + assert token_request.headers["Content-Type"] == "application/x-www-form-urlencoded" + assert token_settings["timeout"] == 3 + assert token_settings["verify"] == "token-ca.pem" + assert token_settings["cert"] == "token-client.pem" + assert resource_request is prepared + assert resource_request.headers["Authorization"] == "DPoP token" + assert resource_request.headers["DPoP"] != "stale-proof" + assert resource_settings["timeout"] == 3 + assert resource_settings["verify"] is False + assert resource_settings["cert"] == "client.pem" + + +def test_requests_session_keeps_resource_session_state_out_of_token_request() -> None: + adapter = StubRequestsAdapter() + session = OAuth2Session(integration_config()) + session.auth = requests.auth.HTTPBasicAuth("resource-user", "resource-password") + session.cookies.set("resource-cookie", "value", domain="auth.example.com") + session.hooks["response"].append(lambda response, **kwargs: response) + session.mount("https://", adapter) + + session.get("https://api.example.com/resources") + + token_request = adapter.calls[0][0] + assert "Authorization" not in token_request.headers + assert "Cookie" not in token_request.headers + assert token_request.hooks["response"] == [] + + +@pytest.mark.parametrize( + "body", + [ + pytest.param(BytesIO(b'{"name":"streamed-resource"}'), id="file-like"), + pytest.param(iter((b"first", b"second")), id="generator"), + ], +) +def test_requests_session_rejects_streaming_body_before_transmission(body: object) -> None: + adapter = StubRequestsAdapter() + session = OAuth2Session(integration_config()) + session.mount("https://", adapter) + prepared = session.prepare_request( + requests.Request( + "POST", + "https://api.example.com/resources", + data=body, + ) + ) + + with pytest.raises(requests.exceptions.UnrewindableBodyError, match="streaming request bodies"): + session.send(prepared) + + assert adapter.calls == [] + + +def test_requests_session_closes_mounted_adapter() -> None: + adapter = StubRequestsAdapter() + session = OAuth2Session(integration_config()) + session.mount("https://", adapter) + + session.close() + + assert adapter.closed + + +def test_requests_session_resolves_environment_proxy_for_token_url(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example.com:8080") + monkeypatch.setenv("NO_PROXY", "api.example.com") + monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + monkeypatch.delenv("CURL_CA_BUNDLE", raising=False) + adapter = StubRequestsAdapter() + session = OAuth2Session(integration_config()) + session.mount("https://", adapter) + + session.get("https://api.example.com/resources") + + token_settings = adapter.calls[0][1] + resource_settings = adapter.calls[1][1] + assert token_settings["proxies"]["https"] == "http://proxy.example.com:8080" + assert resource_settings["proxies"] == {} + + +def test_requests_session_bypasses_proxy_for_token_url_only(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example.com:8080") + monkeypatch.setenv("NO_PROXY", "auth.example.com") + monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + monkeypatch.delenv("CURL_CA_BUNDLE", raising=False) + adapter = StubRequestsAdapter() + session = OAuth2Session(integration_config()) + session.mount("https://", adapter) + + session.get("https://api.example.com/resources") + + token_settings = adapter.calls[0][1] + resource_settings = adapter.calls[1][1] + assert token_settings["proxies"] == {} + assert resource_settings["proxies"]["https"] == "http://proxy.example.com:8080" + From cf9e4480a4974a167aeee52fbca821e628d3a581 Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Tue, 22 Sep 2026 23:12:22 +0530 Subject: [PATCH 04/36] Add Mastercard sandbox tests --- .env.example | 18 ++ .github/workflows/live-test.yml | 140 ++++++++++++ README.md | 48 ++++- pyproject.toml | 5 +- scripts/generate_test_clients.py | 27 ++- tests/live/__init__.py | 1 + tests/live/configuration.py | 123 +++++++++++ tests/live/direct_clients.py | 250 ++++++++++++++++++++++ tests/live/generated_clients.py | 194 +++++++++++++++++ tests/live/lifecycle.py | 77 +++++++ tests/live/test_mastercard_petstore.py | 92 ++++++++ tests/resources/openapi/petstore-api.yaml | 133 ++++++++++++ tests/unit/test_live_configuration.py | 73 +++++++ tests/unit/test_live_lifecycle.py | 55 +++++ 14 files changed, 1226 insertions(+), 10 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/live-test.yml create mode 100644 tests/live/__init__.py create mode 100644 tests/live/configuration.py create mode 100644 tests/live/direct_clients.py create mode 100644 tests/live/generated_clients.py create mode 100644 tests/live/lifecycle.py create mode 100644 tests/live/test_mastercard_petstore.py create mode 100644 tests/resources/openapi/petstore-api.yaml create mode 100644 tests/unit/test_live_configuration.py create mode 100644 tests/unit/test_live_lifecycle.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8233d63 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Copy to .env and fill in the required values. Never commit .env or key files. +CLIENT_ID= +KID= +TOKEN_ENDPOINT=https://sandbox.api.mastercard.com/oauth/token +ISSUER=https://sandbox.api.mastercard.com +API_BASE_URL=https://sandbox.api.mastercard.com/oauth2-petstore/petstore +SCOPES= + +# GitHub Actions uses PRIVATE_KEY with PEM content. For local runs, prefer +# PRIVATE_KEY_PATH pointing to an existing key file. Do not copy private keys +# into this Git checkout. +PRIVATE_KEY= +PRIVATE_KEY_PATH= +PRIVATE_KEY_PASSWORD= + +# Optional network settings. +SSL_CA_CERT= +GATEWAY_HTTPS_PROXY= \ No newline at end of file diff --git a/.github/workflows/live-test.yml b/.github/workflows/live-test.yml new file mode 100644 index 0000000..8bf48a3 --- /dev/null +++ b/.github/workflows/live-test.yml @@ -0,0 +1,140 @@ +name: Mastercard Live Tests + +on: + workflow_dispatch: + schedule: + - cron: '0 6 * * 1' + +env: + PYTHON_VERSION: '3.12' + +permissions: + contents: read + +concurrency: + group: mastercard-live-tests + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + + - name: Build wheel + run: | + python -m pip install --upgrade pip + python -m pip install build + python -m build --wheel + + - name: Upload wheel + uses: actions/upload-artifact@v4 + with: + name: oauth2-client-wheel + path: dist/*.whl + if-no-files-found: error + retention-days: 1 + + live-tests: + needs: build + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 3 + matrix: + include: + - client: requests + algorithm: ec + - client: httpx + algorithm: ec + - client: httpx-async + algorithm: ec + - client: aiohttp + algorithm: ec + - client: openapi-urllib3 + algorithm: ec + - client: openapi-urllib3 + algorithm: rsa + - client: openapi-httpx + algorithm: ec + - client: openapi-asyncio + algorithm: ec + name: ${{ matrix.client }} / ${{ matrix.algorithm }} + env: + LIVE_TEST_REQUIRED: 'true' + LIVE_HTTP_CLIENT: ${{ matrix.client }} + LIVE_DPOP_ALGORITHM: ${{ matrix.algorithm }} + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + + - name: Download wheel + uses: actions/download-artifact@v5 + with: + name: oauth2-client-wheel + path: dist + + - name: Install built wheel and live-test dependencies + run: | + python -m pip install --upgrade pip + wheel=$(find dist -name '*.whl' -print -quit) + test -n "$wheel" + python -m pip install "${wheel}[dev]" + python -m pip check + + - name: Install OpenAPI Generator + if: startsWith(matrix.client, 'openapi-') + run: | + wheel=$(find dist -name '*.whl' -print -quit) + python -m pip install "${wheel}[generator]" + + - name: Generate live OpenAPI clients + if: startsWith(matrix.client, 'openapi-') + run: python scripts/generate_test_clients.py --live + + - name: Verify installed package is used + run: | + python - <<'PY' + from pathlib import Path + import mastercard_oauth2_client + + module_path = Path(mastercard_oauth2_client.__file__).resolve() + source_root = (Path.cwd() / "src").resolve() + if module_path.is_relative_to(source_root): + raise SystemExit("Live tests must use the installed wheel") + PY + + - name: Run Mastercard sandbox tests + env: + CLIENT_ID: ${{ secrets.CLIENT_ID }} + KID: ${{ secrets.KID }} + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + PRIVATE_KEY_PASSWORD: ${{ secrets.PRIVATE_KEY_PASSWORD }} + TOKEN_ENDPOINT: ${{ secrets.TOKEN_ENDPOINT }} + ISSUER: ${{ secrets.ISSUER }} + API_BASE_URL: ${{ secrets.API_BASE_URL }} + SCOPES: ${{ secrets.SCOPES }} + run: >- + python -m pytest + -c /dev/null + --rootdir=. + -o "markers=live: Mastercard sandbox tests that require credentials" + tests/live/test_mastercard_petstore.py + -m live + -v + -s + --tb=short + -rs diff --git a/README.md b/README.md index b0da50b..5abe72b 100644 --- a/README.md +++ b/README.md @@ -297,12 +297,56 @@ and response bodies are not logged. handlers and adapters. - [`tests/integration`](tests/integration) tests direct and generated clients against local HTTPS authorization and resource servers. -- [`fake-api.yaml`](tests/resources/openapi/fake-api.yaml) defines the API used - to generate test clients. +- [`tests/live`](tests/live) calls the real Mastercard sandbox. These optional + tests skip when sandbox credentials are not configured. + +The OpenAPI specifications have separate purposes: + +- [`fake-api.yaml`](tests/resources/openapi/fake-api.yaml) generates clients for + deterministic local integration tests. +- [`petstore-api.yaml`](tests/resources/openapi/petstore-api.yaml) generates + clients for real Mastercard sandbox tests. Pytest markers group each integration's unit and integration tests for focused CI runs. Tests added to an existing marked module inherit its marker. +For normal local development: + +```bash +python -m pytest tests/unit # fast unit tests +python -m pytest tests/integration # local HTTPS integration tests +python scripts/run_tests.py # complete deterministic suite +python scripts/run_tests.py -m requests # one integration +python scripts/run_tests.py -m "httpx or aiohttp" # selected integrations +``` + +To run sandbox tests locally, set the variables shown in +[`.env.example`](.env.example). You may export them in your shell or copy that +file to `.env` as a convenience. Set `LIVE_HTTP_CLIENT` to `requests`, `httpx`, +`httpx-async`, `aiohttp`, `openapi-urllib3`, `openapi-httpx` or +`openapi-asyncio`, and set `LIVE_DPOP_ALGORITHM` to `ec` or `rsa`. + +For a direct integration: + +```bash +LIVE_HTTP_CLIENT=requests LIVE_DPOP_ALGORITHM=ec \ + python -m pytest tests/live/test_mastercard_petstore.py -m live -v -s --tb=short -rs +``` + +For an OpenAPI integration, generate the Petstore clients first: + +```bash +python scripts/generate_test_clients.py --live +LIVE_HTTP_CLIENT=openapi-urllib3 LIVE_DPOP_ALGORITHM=ec \ + python -m pytest tests/live/test_mastercard_petstore.py -m live -v -s --tb=short -rs +``` + +### Test failures + +- Start with the narrowest failing test: `python -m pytest path/to/test.py::test_name -v`. +- Sandbox tests skip when no sandbox settings are configured. Partial settings + fail and name the missing variables. + ## Development Install the development tools and pinned default OpenAPI Generator, then run diff --git a/pyproject.toml b/pyproject.toml index bb43d62..a9a1669 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,10 +22,12 @@ dev = [ "pydantic>=2.11", "pytest>=8.4", "python-dateutil>=2.8.2", + "python-dotenv>=1.1", "requests>=2.33,<3", "ruff>=0.12", "twine>=6.1", "types-requests>=2.33", + "truststore>=0.10", "typing-extensions>=4.14", "urllib3>=2.1,<3", "httpx>=0.28,<1", @@ -47,13 +49,14 @@ generator = [ packages = ["src/mastercard_oauth2_client"] [tool.pytest.ini_options] -testpaths = ["tests"] +testpaths = ["tests/unit", "tests/integration"] addopts = "--strict-markers --strict-config" pythonpath = ["src", "tests/generated/openapi", "tests/generated/openapi_httpx", "tests/generated/openapi_asyncio"] markers = [ "aiohttp: aiohttp direct and generated-client integration tests", "generated: OpenAPI-generated client tests", "httpx: HTTPX direct and generated-client integration tests", + "live: Mastercard sandbox tests that require credentials", "requests: Requests direct integration tests", "urllib3: generated urllib3 client tests", ] diff --git a/scripts/generate_test_clients.py b/scripts/generate_test_clients.py index 433dceb..60946cd 100644 --- a/scripts/generate_test_clients.py +++ b/scripts/generate_test_clients.py @@ -1,14 +1,15 @@ from __future__ import annotations +import argparse import shutil from pathlib import Path import openapi_generator_cli ROOT = Path(__file__).resolve().parents[1] -SPECIFICATION = ROOT / "tests" / "resources" / "openapi" / "fake-api.yaml" GENERATED_ROOT = ROOT / "tests" / "generated" -GENERATED_CLIENTS = ( +TEST_SPECIFICATION = ROOT / "tests" / "resources" / "openapi" / "fake-api.yaml" +TEST_CLIENTS = ( (GENERATED_ROOT / "openapi", "generated_test_client", "oauth2-generated-test-client", None), ( GENERATED_ROOT / "openapi_httpx", @@ -23,17 +24,29 @@ "asyncio", ), ) +LIVE_SPECIFICATION = ROOT / "tests" / "resources" / "openapi" / "petstore-api.yaml" +LIVE_CLIENTS = ( + (GENERATED_ROOT / "live_urllib3", "live_urllib3_client", "oauth2-live-urllib3-client", None), + (GENERATED_ROOT / "live_httpx", "live_httpx_client", "oauth2-live-httpx-client", "httpx"), + (GENERATED_ROOT / "live_asyncio", "live_asyncio_client", "oauth2-live-asyncio-client", "asyncio"), +) def main() -> None: - if not SPECIFICATION.is_file(): - raise SystemExit(f"OpenAPI specification not found: {SPECIFICATION}") + parser = argparse.ArgumentParser(description="Generate OpenAPI clients used by tests") + parser.add_argument("--live", action="store_true", help="generate Mastercard live-test clients") + arguments = parser.parse_args() + specification = LIVE_SPECIFICATION if arguments.live else TEST_SPECIFICATION + clients = LIVE_CLIENTS if arguments.live else TEST_CLIENTS + if not specification.is_file(): + raise SystemExit(f"OpenAPI specification not found: {specification}") - for output, package_name, project_name, library in GENERATED_CLIENTS: - _generate_client(output, package_name, project_name, library) + for output, package_name, project_name, library in clients: + _generate_client(specification, output, package_name, project_name, library) def _generate_client( + specification: Path, output: Path, package_name: str, project_name: str, @@ -43,7 +56,7 @@ def _generate_client( arguments = [ "generate", "-i", - str(SPECIFICATION), + str(specification), "-g", "python", "-o", diff --git a/tests/live/__init__.py b/tests/live/__init__.py new file mode 100644 index 0000000..ac180c6 --- /dev/null +++ b/tests/live/__init__.py @@ -0,0 +1 @@ +"""Mastercard sandbox tests that require credentials.""" \ No newline at end of file diff --git a/tests/live/configuration.py b/tests/live/configuration.py new file mode 100644 index 0000000..093821d --- /dev/null +++ b/tests/live/configuration.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from os import environ +from pathlib import Path + +from cryptography.hazmat.primitives.asymmetric import ec, rsa +from dotenv import load_dotenv + +from mastercard_oauth2_client import ( + KeyPair, + OAuth2Config, + PrivateKey, + StaticDPoPKeyProvider, + StaticScopeResolver, + load_pkcs12_private_key, + load_private_key, +) +from mastercard_oauth2_client.exceptions import OAuth2ConfigError + +_REQUIRED_VALUES = ("CLIENT_ID", "KID", "TOKEN_ENDPOINT", "ISSUER", "API_BASE_URL", "SCOPES") +_KEY_VALUES = ("PRIVATE_KEY", "PRIVATE_KEY_PATH") +_OPTIONAL_VALUES = ("PRIVATE_KEY_PASSWORD", "SSL_CA_CERT", "GATEWAY_HTTPS_PROXY") +_ALL_VALUES = (*_REQUIRED_VALUES, *_KEY_VALUES, *_OPTIONAL_VALUES) +_ROOT = Path(__file__).resolve().parents[2] + + +class LiveConfigurationError(ValueError): + """Report invalid live-test configuration without exposing values.""" + + +@dataclass(frozen=True, slots=True) +class LiveSettings: + client_id: str + key_id: str + private_key: PrivateKey + token_endpoint: str + issuer: str + api_base_url: str + scopes: frozenset[str] + ca_certificate: str | None + proxy: str | None + + +def build_oauth2_config(settings: LiveSettings, algorithm: str) -> OAuth2Config: + if algorithm == "ec": + dpop_private_key: PrivateKey = ec.generate_private_key(ec.SECP256R1()) + elif algorithm == "rsa": + dpop_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + else: + raise LiveConfigurationError(f"Unsupported DPoP algorithm: {algorithm}") + + return OAuth2Config( + client_id=settings.client_id, + key_id=settings.key_id, + client_key=settings.private_key, + token_endpoint=settings.token_endpoint, + issuer=settings.issuer, + scope_resolver=StaticScopeResolver(set(settings.scopes)), + dpop_key_provider=StaticDPoPKeyProvider( + KeyPair(private_key=dpop_private_key, public_key=dpop_private_key.public_key()) + ), + ) + + +def load_live_settings( + environment: Mapping[str, str] | None = None, + *, + required: bool = False, +) -> LiveSettings | None: + """Load complete live settings, skip an absent local setup, and reject partial setup.""" + + if environment is None: + load_dotenv(_ROOT / ".env") + environment = environ + values = {name: environment.get(name, "") for name in _ALL_VALUES} + configured = {name for name, value in values.items() if value.strip()} + if not configured: + if required: + raise LiveConfigurationError("Live test configuration is required but was not provided") + return None + + missing = [name for name in _REQUIRED_VALUES if not values[name].strip()] + if not any(values[name].strip() for name in _KEY_VALUES): + missing.append("PRIVATE_KEY") + if missing: + raise LiveConfigurationError(f"Incomplete live test configuration; missing: {', '.join(sorted(missing))}") + if all(values[name].strip() for name in _KEY_VALUES): + raise LiveConfigurationError("Set only one of PRIVATE_KEY or PRIVATE_KEY_PATH") + + scopes = frozenset(values["SCOPES"].replace(",", " ").split()) + if not scopes: + raise LiveConfigurationError("SCOPES must contain at least one scope") + + password = values["PRIVATE_KEY_PASSWORD"] or None + try: + private_key = _load_authentication_key(values, password) + except (OSError, OAuth2ConfigError) as error: + source_name = "PRIVATE_KEY" if values["PRIVATE_KEY"] else "PRIVATE_KEY_PATH" + raise LiveConfigurationError(f"Unable to load private key from {source_name}") from error + + return LiveSettings( + client_id=values["CLIENT_ID"].strip(), + key_id=values["KID"].strip(), + private_key=private_key, + token_endpoint=values["TOKEN_ENDPOINT"].strip(), + issuer=values["ISSUER"].strip(), + api_base_url=values["API_BASE_URL"].strip().rstrip("/"), + scopes=scopes, + ca_certificate=values["SSL_CA_CERT"].strip() or None, + proxy=values["GATEWAY_HTTPS_PROXY"].strip() or None, + ) + + +def _load_authentication_key(values: Mapping[str, str], password: str | None) -> PrivateKey: + key_content = values["PRIVATE_KEY"] + if key_content.strip(): + return load_private_key(key_content.encode(), password) + key_path = values["PRIVATE_KEY_PATH"].strip() + if Path(key_path).suffix.casefold() in {".p12", ".pfx"}: + return load_pkcs12_private_key(key_path, password) + return load_private_key(key_path, password) \ No newline at end of file diff --git a/tests/live/direct_clients.py b/tests/live/direct_clients.py new file mode 100644 index 0000000..a157a76 --- /dev/null +++ b/tests/live/direct_clients.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import asyncio +import ssl +from time import sleep +from typing import Any +from urllib.parse import urljoin + +import aiohttp +import httpx +import requests + +from mastercard_oauth2_client import OAuth2Config +from mastercard_oauth2_client.integrations.aiohttp import OAuth2Middleware +from mastercard_oauth2_client.integrations.httpx import AsyncOAuth2Transport, OAuth2Transport +from mastercard_oauth2_client.integrations.requests import OAuth2Session + +from .configuration import LiveSettings +from .lifecycle import run_async_dog_lifecycle, run_dog_lifecycle + +# Retry only transient 404s while a new sandbox resource becomes visible. +_MAX_PROPAGATION_ATTEMPTS = 10 +_RETRY_DELAY = 1.0 + + +def run_requests(settings: LiveSettings, config: OAuth2Config, scenario: str) -> None: + with OAuth2Session(config) as client: + client.verify = settings.ca_certificate or True + if settings.proxy: + client.proxies["https"] = settings.proxy + operations = _RequestsOperations(client, settings.api_base_url) + _run_sync_scenario(operations, scenario) + + +def run_httpx(settings: LiveSettings, config: OAuth2Config, scenario: str) -> None: + verify: ssl.SSLContext | bool = ( + ssl.create_default_context(cafile=settings.ca_certificate) if settings.ca_certificate else True + ) + base_transport = httpx.HTTPTransport(verify=verify, proxy=settings.proxy) + with httpx.Client( + base_url=settings.api_base_url, + transport=OAuth2Transport(config, base_transport), + ) as client: + _run_sync_scenario(_HttpxOperations(client), scenario) + + +def run_httpx_async(settings: LiveSettings, config: OAuth2Config, scenario: str) -> None: + asyncio.run(_run_httpx_async(settings, config, scenario)) + + +async def _run_httpx_async(settings: LiveSettings, config: OAuth2Config, scenario: str) -> None: + verify: ssl.SSLContext | bool = ( + ssl.create_default_context(cafile=settings.ca_certificate) if settings.ca_certificate else True + ) + base_transport = httpx.AsyncHTTPTransport(verify=verify, proxy=settings.proxy) + async with httpx.AsyncClient( + base_url=settings.api_base_url, + transport=AsyncOAuth2Transport(config, base_transport), + ) as client: + await _run_async_scenario(_AsyncHttpxOperations(client), scenario) + + +def run_aiohttp(settings: LiveSettings, config: OAuth2Config, scenario: str) -> None: + asyncio.run(_run_aiohttp(settings, config, scenario)) + + +async def _run_aiohttp(settings: LiveSettings, config: OAuth2Config, scenario: str) -> None: + ssl_context = ssl.create_default_context(cafile=settings.ca_certificate) + async with aiohttp.ClientSession( + connector=aiohttp.TCPConnector(ssl=ssl_context), + middlewares=(OAuth2Middleware(config),), + ) as client: + await _run_async_scenario(_AiohttpOperations(client, settings.api_base_url, settings.proxy), scenario) + + +def _run_sync_scenario(operations: Any, scenario: str) -> None: + if scenario == "filtered-search": + operations.search_pets_filtered() + return + if scenario == "dog-lifecycle": + run_dog_lifecycle(operations) + return + raise ValueError(f"Unsupported live scenario: {scenario}") + + +async def _run_async_scenario(operations: Any, scenario: str) -> None: + if scenario == "filtered-search": + await operations.search_pets_filtered() + return + if scenario == "dog-lifecycle": + await run_async_dog_lifecycle(operations) + return + raise ValueError(f"Unsupported live scenario: {scenario}") + + +class _RequestsOperations: + def __init__(self, client: requests.Session, base_url: str) -> None: + self._client = client + self._base_url = base_url + + def search_pets_filtered(self) -> None: + response = self._client.get( + _url(self._base_url, "/pets"), + params=(("status", "AVAILABLE"), ("limit", "1"), ("offset", "0"), ("sort", "+")), + ) + response.raise_for_status() + + def create_dog(self, dog: dict[str, object]) -> str: + response = self._client.post(_url(self._base_url, "/dogs"), json=dog) + response.raise_for_status() + return _required_id(response.json()) + + def get_dog(self, dog_id: str) -> str: + for attempt in range(_MAX_PROPAGATION_ATTEMPTS): + response = self._client.get(_url(self._base_url, f"/dogs/{dog_id}")) + if response.status_code != 404 or attempt == _MAX_PROPAGATION_ATTEMPTS - 1: + response.raise_for_status() + return _required_id(response.json()) + sleep(_RETRY_DELAY) + raise AssertionError("Unreachable dog lookup state") + + def delete_pet(self, pet_id: str) -> None: + for attempt in range(_MAX_PROPAGATION_ATTEMPTS): + response = self._client.delete(_url(self._base_url, f"/pets/{pet_id}")) + if response.status_code != 404 or attempt == _MAX_PROPAGATION_ATTEMPTS - 1: + response.raise_for_status() + return + sleep(_RETRY_DELAY) + + +class _HttpxOperations: + def __init__(self, client: httpx.Client) -> None: + self._client = client + + def search_pets_filtered(self) -> None: + response = self._client.get( + "/pets", + params={"status": "AVAILABLE", "limit": 1, "offset": 0, "sort": "+"}, + ) + response.raise_for_status() + + def create_dog(self, dog: dict[str, object]) -> str: + response = self._client.post("/dogs", json=dog) + response.raise_for_status() + return _required_id(response.json()) + + def get_dog(self, dog_id: str) -> str: + for attempt in range(_MAX_PROPAGATION_ATTEMPTS): + response = self._client.get(f"/dogs/{dog_id}") + if response.status_code != 404 or attempt == _MAX_PROPAGATION_ATTEMPTS - 1: + response.raise_for_status() + return _required_id(response.json()) + sleep(_RETRY_DELAY) + raise AssertionError("Unreachable dog lookup state") + + def delete_pet(self, pet_id: str) -> None: + for attempt in range(_MAX_PROPAGATION_ATTEMPTS): + response = self._client.delete(f"/pets/{pet_id}") + if response.status_code != 404 or attempt == _MAX_PROPAGATION_ATTEMPTS - 1: + response.raise_for_status() + return + sleep(_RETRY_DELAY) + + +class _AsyncHttpxOperations: + def __init__(self, client: httpx.AsyncClient) -> None: + self._client = client + + async def search_pets_filtered(self) -> None: + response = await self._client.get( + "/pets", + params={"status": "AVAILABLE", "limit": 1, "offset": 0, "sort": "+"}, + ) + response.raise_for_status() + + async def create_dog(self, dog: dict[str, object]) -> str: + response = await self._client.post("/dogs", json=dog) + response.raise_for_status() + return _required_id(response.json()) + + async def get_dog(self, dog_id: str) -> str: + for attempt in range(_MAX_PROPAGATION_ATTEMPTS): + response = await self._client.get(f"/dogs/{dog_id}") + if response.status_code != 404 or attempt == _MAX_PROPAGATION_ATTEMPTS - 1: + response.raise_for_status() + return _required_id(response.json()) + await asyncio.sleep(_RETRY_DELAY) + raise AssertionError("Unreachable dog lookup state") + + async def delete_pet(self, pet_id: str) -> None: + for attempt in range(_MAX_PROPAGATION_ATTEMPTS): + response = await self._client.delete(f"/pets/{pet_id}") + if response.status_code != 404 or attempt == _MAX_PROPAGATION_ATTEMPTS - 1: + response.raise_for_status() + return + await asyncio.sleep(_RETRY_DELAY) + + +class _AiohttpOperations: + def __init__(self, client: aiohttp.ClientSession, base_url: str, proxy: str | None) -> None: + self._client = client + self._base_url = base_url + self._proxy = proxy + + async def search_pets_filtered(self) -> None: + response = await self._client.get( + _url(self._base_url, "/pets"), + params={"status": "AVAILABLE", "limit": 1, "offset": 0, "sort": "+"}, + proxy=self._proxy, + ) + response.raise_for_status() + await response.read() + + async def create_dog(self, dog: dict[str, object]) -> str: + response = await self._client.post(_url(self._base_url, "/dogs"), json=dog, proxy=self._proxy) + response.raise_for_status() + return _required_id(await response.json()) + + async def get_dog(self, dog_id: str) -> str: + for attempt in range(_MAX_PROPAGATION_ATTEMPTS): + response = await self._client.get(_url(self._base_url, f"/dogs/{dog_id}"), proxy=self._proxy) + if response.status != 404 or attempt == _MAX_PROPAGATION_ATTEMPTS - 1: + response.raise_for_status() + return _required_id(await response.json()) + await response.read() + await asyncio.sleep(_RETRY_DELAY) + raise AssertionError("Unreachable dog lookup state") + + async def delete_pet(self, pet_id: str) -> None: + for attempt in range(_MAX_PROPAGATION_ATTEMPTS): + response = await self._client.delete(_url(self._base_url, f"/pets/{pet_id}"), proxy=self._proxy) + if response.status != 404 or attempt == _MAX_PROPAGATION_ATTEMPTS - 1: + response.raise_for_status() + await response.read() + return + await response.read() + await asyncio.sleep(_RETRY_DELAY) + + +def _required_id(value: object) -> str: + if not isinstance(value, dict): + raise TypeError("Gateway response must be a JSON object") + resource_id = value.get("id") + if not isinstance(resource_id, str) or not resource_id: + raise TypeError("Gateway response did not contain a valid ID") + return resource_id + + +def _url(base_url: str, path: str) -> str: + return urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/")) diff --git a/tests/live/generated_clients.py b/tests/live/generated_clients.py new file mode 100644 index 0000000..87a3707 --- /dev/null +++ b/tests/live/generated_clients.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import asyncio +import importlib +import sys +from pathlib import Path +from time import sleep +from types import ModuleType +from typing import Any +from uuid import UUID + +from mastercard_oauth2_client import OAuth2Config, add_oauth2_layer +from mastercard_oauth2_client.integrations.openapi.asyncio import add_oauth2_layer as add_asyncio_oauth2_layer +from mastercard_oauth2_client.integrations.openapi.httpx import add_oauth2_layer as add_httpx_oauth2_layer + +from .configuration import LiveSettings +from .lifecycle import run_async_dog_lifecycle, run_dog_lifecycle + +_GENERATED_ROOT = Path(__file__).resolve().parents[1] / "generated" +# Retry only transient 404s while a new sandbox resource becomes visible. +_MAX_PROPAGATION_ATTEMPTS = 10 +_RETRY_DELAY = 1.0 + + +def run_openapi_urllib3(settings: LiveSettings, config: OAuth2Config, scenario: str) -> None: + gateway = _generated_client("live_urllib3", "live_urllib3_client") + configuration = gateway.Configuration(host=settings.api_base_url) + _apply_generated_settings(configuration, settings) + api_client = gateway.ApiClient(configuration) + add_oauth2_layer(api_client, config) + try: + _run_sync_scenario(_GeneratedSyncOperations(gateway, api_client), scenario) + finally: + api_client.rest_client.pool_manager.clear() + + +def run_openapi_httpx(settings: LiveSettings, config: OAuth2Config, scenario: str) -> None: + asyncio.run(_run_openapi_httpx(settings, config, scenario)) + + +async def _run_openapi_httpx(settings: LiveSettings, config: OAuth2Config, scenario: str) -> None: + gateway = _generated_client("live_httpx", "live_httpx_client") + configuration = gateway.Configuration(host=settings.api_base_url) + _apply_generated_settings(configuration, settings) + api_client = gateway.ApiClient(configuration) + add_httpx_oauth2_layer(api_client, config) + try: + await _run_async_scenario(_GeneratedAsyncOperations(gateway, api_client), scenario) + finally: + await api_client.close() + + +def run_openapi_asyncio(settings: LiveSettings, config: OAuth2Config, scenario: str) -> None: + asyncio.run(_run_openapi_asyncio(settings, config, scenario)) + + +async def _run_openapi_asyncio(settings: LiveSettings, config: OAuth2Config, scenario: str) -> None: + gateway = _generated_client("live_asyncio", "live_asyncio_client") + configuration = gateway.Configuration(host=settings.api_base_url) + _apply_generated_settings(configuration, settings) + api_client = gateway.ApiClient(configuration) + add_asyncio_oauth2_layer(api_client, config) + try: + await _run_async_scenario(_GeneratedAsyncOperations(gateway, api_client), scenario) + finally: + await api_client.close() + + +def _generated_client(directory: str, package: str) -> ModuleType: + root = _GENERATED_ROOT / directory + if not root.is_dir(): + raise RuntimeError("Generated live client is missing; run scripts/generate_test_clients.py --live") + path = str(root) + if path not in sys.path: + sys.path.insert(0, path) + return importlib.import_module(package) + + +def _apply_generated_settings(configuration: Any, settings: LiveSettings) -> None: + if settings.ca_certificate: + configuration.ssl_ca_cert = settings.ca_certificate + if settings.proxy: + configuration.proxy = settings.proxy + + +def _run_sync_scenario(operations: Any, scenario: str) -> None: + if scenario == "filtered-search": + operations.search_pets_filtered() + return + if scenario == "dog-lifecycle": + run_dog_lifecycle(operations) + return + raise ValueError(f"Unsupported live scenario: {scenario}") + + +async def _run_async_scenario(operations: Any, scenario: str) -> None: + if scenario == "filtered-search": + await operations.search_pets_filtered() + return + if scenario == "dog-lifecycle": + await run_async_dog_lifecycle(operations) + return + raise ValueError(f"Unsupported live scenario: {scenario}") + + +def _generated_dog(gateway: ModuleType, dog: dict[str, object]) -> object: + birthdate = dog["birthdate"] + status = dog["status"] + if not isinstance(birthdate, dict) or not isinstance(status, dict): + raise TypeError("Live dog payload is invalid") + return gateway.NewDog( + status=gateway.PetStatus(value=status["value"]), + name=dog["name"], + breed=dog["breed"], + color=dog["color"], + gender=dog["gender"], + birthdate=gateway.Birthdate(**birthdate), + dog_property2=dog["dogProperty2"], + ) + + +class _GeneratedSyncOperations: + def __init__(self, gateway: ModuleType, api_client: object) -> None: + self._gateway = gateway + self._dogs = gateway.DogsApi(api_client) + self._pets = gateway.PetsApi(api_client) + + def search_pets_filtered(self) -> None: + self._pets.search_pets(status="AVAILABLE", limit=1, offset=0, sort="+") + + def create_dog(self, dog: dict[str, object]) -> str: + return _model_id(self._dogs.add_dog(_generated_dog(self._gateway, dog))) + + def get_dog(self, dog_id: str) -> str: + for attempt in range(_MAX_PROPAGATION_ATTEMPTS): + try: + return _model_id(self._dogs.get_dog(UUID(dog_id))) + except Exception as error: + if getattr(error, "status", None) != 404 or attempt == _MAX_PROPAGATION_ATTEMPTS - 1: + raise + sleep(_RETRY_DELAY) + raise AssertionError("Unreachable dog lookup state") + + def delete_pet(self, pet_id: str) -> None: + for attempt in range(_MAX_PROPAGATION_ATTEMPTS): + try: + self._pets.delete_pet(UUID(pet_id)) + return + except Exception as error: + if getattr(error, "status", None) != 404 or attempt == _MAX_PROPAGATION_ATTEMPTS - 1: + raise + sleep(_RETRY_DELAY) + + +class _GeneratedAsyncOperations: + def __init__(self, gateway: ModuleType, api_client: object) -> None: + self._gateway = gateway + self._dogs = gateway.DogsApi(api_client) + self._pets = gateway.PetsApi(api_client) + + async def search_pets_filtered(self) -> None: + await self._pets.search_pets(status="AVAILABLE", limit=1, offset=0, sort="+") + + async def create_dog(self, dog: dict[str, object]) -> str: + return _model_id(await self._dogs.add_dog(_generated_dog(self._gateway, dog))) + + async def get_dog(self, dog_id: str) -> str: + for attempt in range(_MAX_PROPAGATION_ATTEMPTS): + try: + return _model_id(await self._dogs.get_dog(UUID(dog_id))) + except Exception as error: + if getattr(error, "status", None) != 404 or attempt == _MAX_PROPAGATION_ATTEMPTS - 1: + raise + await asyncio.sleep(_RETRY_DELAY) + raise AssertionError("Unreachable dog lookup state") + + async def delete_pet(self, pet_id: str) -> None: + for attempt in range(_MAX_PROPAGATION_ATTEMPTS): + try: + await self._pets.delete_pet(UUID(pet_id)) + return + except Exception as error: + if getattr(error, "status", None) != 404 or attempt == _MAX_PROPAGATION_ATTEMPTS - 1: + raise + await asyncio.sleep(_RETRY_DELAY) + + +def _model_id(model: object) -> str: + resource_id = getattr(model, "id", None) + if isinstance(resource_id, UUID): + return str(resource_id) + if isinstance(resource_id, str) and resource_id: + return resource_id + raise TypeError("Gateway response did not contain a valid ID") diff --git a/tests/live/lifecycle.py b/tests/live/lifecycle.py new file mode 100644 index 0000000..06997a0 --- /dev/null +++ b/tests/live/lifecycle.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from collections.abc import Awaitable +from typing import Protocol +from uuid import uuid4 + + +class LiveOperations(Protocol): + def create_dog(self, dog: dict[str, object]) -> str: ... + + def get_dog(self, dog_id: str) -> str: ... + + def delete_pet(self, pet_id: str) -> None: ... + + +class AsyncLiveOperations(Protocol): + def create_dog(self, dog: dict[str, object]) -> Awaitable[str]: ... + + def get_dog(self, dog_id: str) -> Awaitable[str]: ... + + def delete_pet(self, pet_id: str) -> Awaitable[None]: ... + + +def new_dog() -> dict[str, object]: + return { + "status": {"value": "AVAILABLE"}, + "name": f"OAuth2 Live Test {uuid4().hex[:8]}", + "breed": "Labrador", + "color": "Black", + "gender": "FEMALE", + "birthdate": {"year": 2024, "month": 1, "day": 1}, + "dogProperty2": {"source": "oauth2-client-python-live-test"}, + } + + +def run_dog_lifecycle(operations: LiveOperations) -> None: + dog_id = operations.create_dog(new_dog()) + print("dog create: completed") + try: + fetched_id = operations.get_dog(dog_id) + print("dog fetch: completed") + if fetched_id != dog_id: + raise AssertionError("Fetched dog ID did not match the created dog ID") + except Exception as operation_error: + try: + operations.delete_pet(dog_id) + except Exception as cleanup_error: # noqa: BLE001 - preserve both client-specific failures. + raise ExceptionGroup( + "Dog operation and cleanup both failed", + [operation_error, cleanup_error], + ) from None + print("dog cleanup: completed") + raise + operations.delete_pet(dog_id) + print("dog cleanup: completed") + + +async def run_async_dog_lifecycle(operations: AsyncLiveOperations) -> None: + dog_id = await operations.create_dog(new_dog()) + print("dog create: completed") + try: + fetched_id = await operations.get_dog(dog_id) + print("dog fetch: completed") + if fetched_id != dog_id: + raise AssertionError("Fetched dog ID did not match the created dog ID") + except Exception as operation_error: + try: + await operations.delete_pet(dog_id) + except Exception as cleanup_error: # noqa: BLE001 - preserve both client-specific failures. + raise ExceptionGroup( + "Dog operation and cleanup both failed", + [operation_error, cleanup_error], + ) from None + print("dog cleanup: completed") + raise + await operations.delete_pet(dog_id) + print("dog cleanup: completed") \ No newline at end of file diff --git a/tests/live/test_mastercard_petstore.py b/tests/live/test_mastercard_petstore.py new file mode 100644 index 0000000..2b2ac79 --- /dev/null +++ b/tests/live/test_mastercard_petstore.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import os +from collections.abc import Callable + +import pytest +import truststore + +from mastercard_oauth2_client import OAuth2Config + +from .configuration import LiveConfigurationError, LiveSettings, build_oauth2_config, load_live_settings +from .direct_clients import run_aiohttp, run_httpx, run_httpx_async, run_requests + +pytestmark = pytest.mark.live + +LiveRunner = Callable[[LiveSettings, OAuth2Config, str], None] +_DIRECT_RUNNERS: dict[str, LiveRunner] = { + "aiohttp": run_aiohttp, + "httpx": run_httpx, + "httpx-async": run_httpx_async, + "requests": run_requests, +} + + +@pytest.fixture(scope="module") +def live_settings() -> LiveSettings: + truststore.inject_into_ssl() + required = os.getenv("LIVE_TEST_REQUIRED", "").casefold() in {"1", "true", "yes"} + configuration_error: str | None = None + try: + settings = load_live_settings(required=required) + except LiveConfigurationError as error: + settings = None + configuration_error = str(error) + if configuration_error is not None: + pytest.fail(configuration_error, pytrace=False) + if settings is None: + pytest.skip("Mastercard live test configuration is not present") + return settings + + +@pytest.fixture(scope="module") +def live_runner() -> tuple[str, LiveRunner, str]: + client_name = os.getenv("LIVE_HTTP_CLIENT", "openapi-urllib3") + runner = _runner(client_name) + algorithm = os.getenv("LIVE_DPOP_ALGORITHM", "ec").casefold() + if algorithm not in {"ec", "rsa"}: + raise LiveConfigurationError("LIVE_DPOP_ALGORITHM must be ec or rsa") + return client_name, runner, algorithm + + +def _runner(client_name: str) -> LiveRunner: + if client_name in _DIRECT_RUNNERS: + return _DIRECT_RUNNERS[client_name] + from .generated_clients import run_openapi_asyncio, run_openapi_httpx, run_openapi_urllib3 + + generated_runners: dict[str, LiveRunner] = { + "openapi-asyncio": run_openapi_asyncio, + "openapi-httpx": run_openapi_httpx, + "openapi-urllib3": run_openapi_urllib3, + } + try: + return generated_runners[client_name] + except KeyError as error: + supported = ", ".join(sorted((*_DIRECT_RUNNERS, *generated_runners))) + raise LiveConfigurationError(f"Unsupported LIVE_HTTP_CLIENT; expected one of: {supported}") from error + + +def test_mastercard_filtered_pet_search( + live_settings: LiveSettings, + live_runner: tuple[str, LiveRunner, str], +) -> None: + _run(live_settings, live_runner, "filtered-search") + + +def test_mastercard_dog_lifecycle( + live_settings: LiveSettings, + live_runner: tuple[str, LiveRunner, str], +) -> None: + _run(live_settings, live_runner, "dog-lifecycle") + + +def _run( + settings: LiveSettings, + runner_and_algorithm: tuple[str, LiveRunner, str], + scenario: str, +) -> None: + client_name, runner, algorithm = runner_and_algorithm + print(f"live test: client={client_name} algorithm={algorithm} scenario={scenario}") + config = build_oauth2_config(settings, algorithm) + runner(settings, config, scenario) + print(f"live test: scenario={scenario} completed") diff --git a/tests/resources/openapi/petstore-api.yaml b/tests/resources/openapi/petstore-api.yaml new file mode 100644 index 0000000..0fa20d2 --- /dev/null +++ b/tests/resources/openapi/petstore-api.yaml @@ -0,0 +1,133 @@ +openapi: 3.0.3 +info: + title: Mastercard OAuth2 live-test Petstore subset + version: 1.0.0 +servers: + - url: https://sandbox.api.mastercard.com/oauth2-petstore/petstore +paths: + /dogs: + post: + operationId: addDog + tags: [Dogs] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewDog" + responses: + "200": + description: Dog created + content: + application/json: + schema: + $ref: "#/components/schemas/Dog" + /dogs/{pet_id}: + get: + operationId: getDog + tags: [Dogs] + parameters: + - $ref: "#/components/parameters/PetId" + responses: + "200": + description: Dog returned + content: + application/json: + schema: + $ref: "#/components/schemas/Dog" + "404": + description: Dog not found + /pets: + get: + operationId: searchPets + tags: [Pets] + parameters: + - name: status + in: query + schema: + type: string + - name: limit + in: query + schema: + type: integer + - name: offset + in: query + schema: + type: integer + - name: sort + in: query + schema: + type: string + responses: + "200": + description: Pets returned + content: + application/json: + schema: + type: object + additionalProperties: true + /pets/{pet_id}: + delete: + operationId: deletePet + tags: [Pets] + parameters: + - $ref: "#/components/parameters/PetId" + responses: + "204": + description: Pet deleted + "404": + description: Pet not found +components: + parameters: + PetId: + name: pet_id + in: path + required: true + schema: + type: string + format: uuid + schemas: + PetStatus: + type: object + required: [value] + properties: + value: + type: string + Birthdate: + type: object + required: [year, month, day] + properties: + year: + type: integer + month: + type: integer + day: + type: integer + NewDog: + type: object + required: [status, name, breed, color, gender] + properties: + status: + $ref: "#/components/schemas/PetStatus" + name: + type: string + breed: + type: string + color: + type: string + gender: + type: string + birthdate: + $ref: "#/components/schemas/Birthdate" + dogProperty2: + type: object + additionalProperties: true + Dog: + allOf: + - $ref: "#/components/schemas/NewDog" + - type: object + required: [id] + properties: + id: + type: string + format: uuid diff --git a/tests/unit/test_live_configuration.py b/tests/unit/test_live_configuration.py new file mode 100644 index 0000000..a466d82 --- /dev/null +++ b/tests/unit/test_live_configuration.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from collections.abc import Mapping + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from tests.live.configuration import LiveConfigurationError, load_live_settings + + +def test_live_settings_are_absent_when_no_local_configuration_exists() -> None: + assert load_live_settings({}) is None + + +def test_live_settings_require_complete_configuration_once_started() -> None: + environment = {"CLIENT_ID": "configured-client"} + + with pytest.raises(LiveConfigurationError) as error: + load_live_settings(environment) + + message = str(error.value) + assert "Incomplete live test configuration" in message + assert "API_BASE_URL" in message + assert "PRIVATE_KEY" in message + assert "configured-client" not in message + + +def test_live_settings_are_required_in_protected_ci() -> None: + with pytest.raises(LiveConfigurationError, match="Live test configuration is required"): + load_live_settings({}, required=True) + + +def test_live_settings_load_pem_key_content_without_a_file() -> None: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + pem = private_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ).decode() + environment: Mapping[str, str] = { + "CLIENT_ID": "client-id", + "KID": "key-id", + "PRIVATE_KEY": pem, + "TOKEN_ENDPOINT": "https://auth.example.com/oauth/token", + "ISSUER": "https://auth.example.com", + "API_BASE_URL": "https://api.example.com/petstore", + "SCOPES": "pets:read,pets:write", + } + + settings = load_live_settings(environment) + + assert settings is not None + assert settings.client_id == "client-id" + assert settings.key_id == "key-id" + assert settings.private_key.private_numbers() == private_key.private_numbers() + assert settings.scopes == frozenset({"pets:read", "pets:write"}) + + +def test_live_settings_reject_ambiguous_key_sources() -> None: + environment = { + "CLIENT_ID": "client-id", + "KID": "key-id", + "PRIVATE_KEY": "pem-content", + "PRIVATE_KEY_PATH": "/tmp/key.pem", + "TOKEN_ENDPOINT": "https://auth.example.com/oauth/token", + "ISSUER": "https://auth.example.com", + "API_BASE_URL": "https://api.example.com/petstore", + "SCOPES": "pets:read", + } + + with pytest.raises(LiveConfigurationError, match="Set only one of PRIVATE_KEY or PRIVATE_KEY_PATH"): + load_live_settings(environment) \ No newline at end of file diff --git a/tests/unit/test_live_lifecycle.py b/tests/unit/test_live_lifecycle.py new file mode 100644 index 0000000..4fcb6ab --- /dev/null +++ b/tests/unit/test_live_lifecycle.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import pytest + +from tests.live.lifecycle import run_dog_lifecycle + + +class FakeOperations: + def __init__(self, *, fail_get: bool = False, fail_delete: bool = False) -> None: + self.fail_get = fail_get + self.fail_delete = fail_delete + self.events: list[str] = [] + + def create_dog(self, dog: dict[str, object]) -> str: + self.events.append("create") + assert dog["name"].startswith("OAuth2 Live Test ") # type: ignore[union-attr] + return "dog-id" + + def get_dog(self, dog_id: str) -> str: + self.events.append("get") + if self.fail_get: + raise RuntimeError("fetch failed") + return dog_id + + def delete_pet(self, pet_id: str) -> None: + self.events.append("delete") + if self.fail_delete: + raise TimeoutError("cleanup failed") + + +def test_live_lifecycle_creates_fetches_and_deletes() -> None: + operations = FakeOperations() + + run_dog_lifecycle(operations) + + assert operations.events == ["create", "get", "delete"] + + +def test_live_lifecycle_attempts_cleanup_after_fetch_failure() -> None: + operations = FakeOperations(fail_get=True) + + with pytest.raises(RuntimeError, match="fetch failed"): + run_dog_lifecycle(operations) + + assert operations.events == ["create", "get", "delete"] + + +def test_live_lifecycle_preserves_operation_and_cleanup_failures() -> None: + operations = FakeOperations(fail_get=True, fail_delete=True) + + with pytest.raises(ExceptionGroup) as error: + run_dog_lifecycle(operations) + + assert [type(item) for item in error.value.exceptions] == [RuntimeError, TimeoutError] + assert [str(item) for item in error.value.exceptions] == ["fetch failed", "cleanup failed"] \ No newline at end of file From 1fdc6e7380ac0865ca48188bd511d17b6096b492 Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Tue, 22 Sep 2026 23:37:05 +0530 Subject: [PATCH 05/36] Add a temporary push trigger for live tests --- .github/workflows/live-test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/live-test.yml b/.github/workflows/live-test.yml index 8bf48a3..dd40a81 100644 --- a/.github/workflows/live-test.yml +++ b/.github/workflows/live-test.yml @@ -4,6 +4,9 @@ on: workflow_dispatch: schedule: - cron: '0 6 * * 1' + push: + branches: + - feature/initial-implementation env: PYTHON_VERSION: '3.12' From 8a31c108fc095f945297cc434c4e375409757b4a Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Wed, 23 Sep 2026 14:26:42 +0530 Subject: [PATCH 06/36] Simplify test organization and client generation --- .github/workflows/ci-aiohttp.yml | 16 +++++- .github/workflows/ci-httpx.yml | 16 +++++- .github/workflows/ci-openapi.yml | 10 +++- .github/workflows/ci-python.yml | 7 ++- .github/workflows/ci-requests.yml | 9 ++- .github/workflows/ci-urllib3.yml | 10 +++- .github/workflows/live-test.yml | 8 ++- README.md | 57 +++++++++++-------- pyproject.toml | 10 +--- scripts/generate_test_clients.py | 31 +++++----- scripts/run_tests.py | 17 ------ tests/__init__.py | 0 tests/integration/__init__.py | 0 tests/integration/direct/__init__.py | 0 tests/integration/direct/aiohttp/__init__.py | 0 .../aiohttp/test_aiohttp_client.py} | 2 - tests/integration/direct/httpx/__init__.py | 0 .../httpx/test_httpx_async_client.py} | 2 - .../httpx/test_httpx_sync_client.py} | 2 - tests/integration/direct/requests/__init__.py | 0 .../requests/test_requests_client.py} | 2 - tests/integration/openapi/__init__.py | 0 tests/integration/openapi/asyncio/__init__.py | 0 .../asyncio/test_openapi_asyncio_client.py} | 2 - tests/integration/openapi/httpx/__init__.py | 0 .../httpx/test_openapi_httpx_client.py} | 2 - tests/integration/openapi/urllib3/__init__.py | 0 .../urllib3/test_openapi_urllib3_client.py} | 2 - tests/live/test_mastercard_petstore.py | 2 - tests/unit/__init__.py | 0 tests/unit/direct/__init__.py | 0 tests/unit/direct/aiohttp/__init__.py | 0 .../aiohttp/test_aiohttp_middleware.py} | 3 - tests/unit/direct/httpx/__init__.py | 0 .../httpx/test_httpx_async_transport.py} | 3 - .../httpx/test_httpx_transport.py} | 3 - tests/unit/direct/requests/__init__.py | 0 .../requests/test_requests_session.py} | 2 - tests/unit/openapi/__init__.py | 0 tests/unit/openapi/asyncio/__init__.py | 0 .../asyncio}/test_openapi_asyncio_adapter.py | 2 - tests/unit/openapi/httpx/__init__.py | 0 .../httpx}/test_openapi_httpx_adapter.py | 2 - .../openapi/test_generate_test_clients.py | 48 ++++++++++++++++ tests/unit/openapi/urllib3/__init__.py | 0 .../urllib3/test_openapi_urllib3_adapter.py} | 2 - 46 files changed, 160 insertions(+), 112 deletions(-) delete mode 100644 scripts/run_tests.py create mode 100644 tests/__init__.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/direct/__init__.py create mode 100644 tests/integration/direct/aiohttp/__init__.py rename tests/integration/{test_aiohttp.py => direct/aiohttp/test_aiohttp_client.py} (99%) create mode 100644 tests/integration/direct/httpx/__init__.py rename tests/integration/{test_httpx_async.py => direct/httpx/test_httpx_async_client.py} (99%) rename tests/integration/{test_httpx.py => direct/httpx/test_httpx_sync_client.py} (99%) create mode 100644 tests/integration/direct/requests/__init__.py rename tests/integration/{test_requests.py => direct/requests/test_requests_client.py} (99%) create mode 100644 tests/integration/openapi/__init__.py create mode 100644 tests/integration/openapi/asyncio/__init__.py rename tests/integration/{test_openapi_asyncio.py => openapi/asyncio/test_openapi_asyncio_client.py} (99%) create mode 100644 tests/integration/openapi/httpx/__init__.py rename tests/integration/{test_openapi_httpx.py => openapi/httpx/test_openapi_httpx_client.py} (99%) create mode 100644 tests/integration/openapi/urllib3/__init__.py rename tests/integration/{test_openapi.py => openapi/urllib3/test_openapi_urllib3_client.py} (99%) create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/direct/__init__.py create mode 100644 tests/unit/direct/aiohttp/__init__.py rename tests/unit/{test_aiohttp_integration.py => direct/aiohttp/test_aiohttp_middleware.py} (99%) create mode 100644 tests/unit/direct/httpx/__init__.py rename tests/unit/{test_httpx_async_integration.py => direct/httpx/test_httpx_async_transport.py} (98%) rename tests/unit/{test_httpx_integration.py => direct/httpx/test_httpx_transport.py} (97%) create mode 100644 tests/unit/direct/requests/__init__.py rename tests/unit/{test_requests_integration.py => direct/requests/test_requests_session.py} (99%) create mode 100644 tests/unit/openapi/__init__.py create mode 100644 tests/unit/openapi/asyncio/__init__.py rename tests/unit/{ => openapi/asyncio}/test_openapi_asyncio_adapter.py (98%) create mode 100644 tests/unit/openapi/httpx/__init__.py rename tests/unit/{ => openapi/httpx}/test_openapi_httpx_adapter.py (99%) create mode 100644 tests/unit/openapi/test_generate_test_clients.py create mode 100644 tests/unit/openapi/urllib3/__init__.py rename tests/unit/{test_openapi_adapter.py => openapi/urllib3/test_openapi_urllib3_adapter.py} (99%) diff --git a/.github/workflows/ci-aiohttp.yml b/.github/workflows/ci-aiohttp.yml index 754c94c..a444e5d 100644 --- a/.github/workflows/ci-aiohttp.yml +++ b/.github/workflows/ci-aiohttp.yml @@ -40,5 +40,17 @@ jobs: python -m pip install -e ".[dev,generator]" "${{ matrix.aiohttp }}" python -m pip check - - name: Run aiohttp tests - run: python scripts/run_tests.py -q -m aiohttp + - name: Run direct aiohttp unit tests + run: python -m pytest -v tests/unit/direct/aiohttp + + - name: Run direct aiohttp integration tests + run: python -m pytest -v tests/integration/direct/aiohttp + + - name: Generate OpenAPI asyncio test client + run: python scripts/generate_test_clients.py --client asyncio + + - name: Run OpenAPI asyncio unit tests + run: python -m pytest -v tests/unit/openapi/asyncio + + - name: Run OpenAPI asyncio integration tests + run: python -m pytest -v tests/integration/openapi/asyncio diff --git a/.github/workflows/ci-httpx.yml b/.github/workflows/ci-httpx.yml index 9b0fc79..e12016f 100644 --- a/.github/workflows/ci-httpx.yml +++ b/.github/workflows/ci-httpx.yml @@ -40,5 +40,17 @@ jobs: python -m pip install -e ".[dev,generator]" "${{ matrix.httpx }}" python -m pip check - - name: Run HTTPX tests - run: python scripts/run_tests.py -q -m httpx + - name: Run direct HTTPX unit tests + run: python -m pytest -v tests/unit/direct/httpx + + - name: Run direct HTTPX integration tests + run: python -m pytest -v tests/integration/direct/httpx + + - name: Generate OpenAPI HTTPX test client + run: python scripts/generate_test_clients.py --client httpx + + - name: Run OpenAPI HTTPX unit tests + run: python -m pytest -v tests/unit/openapi/httpx + + - name: Run OpenAPI HTTPX integration tests + run: python -m pytest -v tests/integration/openapi/httpx diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml index 030ccd4..78d5e3d 100644 --- a/.github/workflows/ci-openapi.yml +++ b/.github/workflows/ci-openapi.yml @@ -38,5 +38,11 @@ jobs: python -m pip install -e ".[dev]" "openapi-generator-cli[jdk4py]==${{ matrix['generator-version'] }}" python -m pip check - - name: Run generated-client tests - run: python scripts/run_tests.py -q -m generated + - name: Generate all OpenAPI test clients + run: python scripts/generate_test_clients.py + + - name: Run OpenAPI unit tests + run: python -m pytest -v tests/unit/openapi + + - name: Run OpenAPI integration tests + run: python -m pytest -v tests/integration/openapi diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml index ef3da9e..dd0ed64 100644 --- a/.github/workflows/ci-python.yml +++ b/.github/workflows/ci-python.yml @@ -42,5 +42,8 @@ jobs: python -m pip install -e ".[dev]" "${{ matrix.cryptography }}" python -m pip check - - name: Run tests - run: python -m pytest -q tests/unit tests/integration --ignore-glob='**/test_openapi*.py' + - name: Run unit tests + run: python -m pytest -v tests/unit/test_*.py tests/unit/direct + + - name: Run direct integration tests + run: python -m pytest -v tests/integration/direct diff --git a/.github/workflows/ci-requests.yml b/.github/workflows/ci-requests.yml index f835d65..01fabd7 100644 --- a/.github/workflows/ci-requests.yml +++ b/.github/workflows/ci-requests.yml @@ -37,8 +37,11 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install -e ".[dev,generator]" "${{ matrix.requests }}" + python -m pip install -e ".[dev]" "${{ matrix.requests }}" python -m pip check - - name: Run Requests tests - run: python scripts/run_tests.py -q -m requests \ No newline at end of file + - name: Run Requests unit tests + run: python -m pytest -v tests/unit/direct/requests + + - name: Run Requests integration tests + run: python -m pytest -v tests/integration/direct/requests \ No newline at end of file diff --git a/.github/workflows/ci-urllib3.yml b/.github/workflows/ci-urllib3.yml index df98eca..1a830e6 100644 --- a/.github/workflows/ci-urllib3.yml +++ b/.github/workflows/ci-urllib3.yml @@ -40,5 +40,11 @@ jobs: python -m pip install -e ".[dev,generator]" "${{ matrix.urllib3 }}" python -m pip check - - name: Run generated urllib3 tests - run: python scripts/run_tests.py -q -m urllib3 + - name: Generate OpenAPI urllib3 test client + run: python scripts/generate_test_clients.py --client urllib3 + + - name: Run OpenAPI urllib3 unit tests + run: python -m pytest -v tests/unit/openapi/urllib3 + + - name: Run OpenAPI urllib3 integration tests + run: python -m pytest -v tests/integration/openapi/urllib3 diff --git a/.github/workflows/live-test.yml b/.github/workflows/live-test.yml index dd40a81..12d058c 100644 --- a/.github/workflows/live-test.yml +++ b/.github/workflows/live-test.yml @@ -63,12 +63,16 @@ jobs: algorithm: ec - client: openapi-urllib3 algorithm: ec + generator: urllib3 - client: openapi-urllib3 algorithm: rsa + generator: urllib3 - client: openapi-httpx algorithm: ec + generator: httpx - client: openapi-asyncio algorithm: ec + generator: asyncio name: ${{ matrix.client }} / ${{ matrix.algorithm }} env: LIVE_TEST_REQUIRED: 'true' @@ -106,7 +110,7 @@ jobs: - name: Generate live OpenAPI clients if: startsWith(matrix.client, 'openapi-') - run: python scripts/generate_test_clients.py --live + run: python scripts/generate_test_clients.py --live --client "${{ matrix.generator }}" - name: Verify installed package is used run: | @@ -134,9 +138,7 @@ jobs: python -m pytest -c /dev/null --rootdir=. - -o "markers=live: Mastercard sandbox tests that require credentials" tests/live/test_mastercard_petstore.py - -m live -v -s --tb=short diff --git a/README.md b/README.md index 5abe72b..c75e6a1 100644 --- a/README.md +++ b/README.md @@ -293,31 +293,48 @@ and response bodies are not logged. ## Test Strategy -- [`tests/unit`](tests/unit) contains fast tests for protocol rules, caching, - handlers and adapters. +- [`tests/unit`](tests/unit) contains fast protocol, handler and adapter tests, + with client-specific tests grouped under `direct` and `openapi`. - [`tests/integration`](tests/integration) tests direct and generated clients - against local HTTPS authorization and resource servers. + against local HTTPS servers, grouped under `direct` and `openapi`. - [`tests/live`](tests/live) calls the real Mastercard sandbox. These optional tests skip when sandbox credentials are not configured. The OpenAPI specifications have separate purposes: - [`fake-api.yaml`](tests/resources/openapi/fake-api.yaml) generates clients for - deterministic local integration tests. + local integration tests. - [`petstore-api.yaml`](tests/resources/openapi/petstore-api.yaml) generates clients for real Mastercard sandbox tests. -Pytest markers group each integration's unit and integration tests for focused -CI runs. Tests added to an existing marked module inherit its marker. +To run all tests that do not require generated OpenAPI clients: -For normal local development: +```bash +# Unit tests +python -m pytest -v tests/unit/test_*.py tests/unit/direct + +# Integration tests +python -m pytest -v tests/integration/direct +``` + +To focus on one client, pass its unit and integration directories. For example, +to test Requests: + +```bash +python -m pytest -v tests/unit/direct/requests tests/integration/direct/requests +``` + +OpenAPI tests require generated clients. Generate all clients for the complete +suite, or select one client for focused tests: ```bash -python -m pytest tests/unit # fast unit tests -python -m pytest tests/integration # local HTTPS integration tests -python scripts/run_tests.py # complete deterministic suite -python scripts/run_tests.py -m requests # one integration -python scripts/run_tests.py -m "httpx or aiohttp" # selected integrations +# Run the complete test suite +python scripts/generate_test_clients.py +python -m pytest -v + +# Run only the OpenAPI HTTPX tests +python scripts/generate_test_clients.py --client httpx +python -m pytest -v tests/unit/openapi/httpx tests/integration/openapi/httpx ``` To run sandbox tests locally, set the variables shown in @@ -330,15 +347,15 @@ For a direct integration: ```bash LIVE_HTTP_CLIENT=requests LIVE_DPOP_ALGORITHM=ec \ - python -m pytest tests/live/test_mastercard_petstore.py -m live -v -s --tb=short -rs + python -m pytest tests/live/test_mastercard_petstore.py -v -s --tb=short -rs ``` For an OpenAPI integration, generate the Petstore clients first: ```bash -python scripts/generate_test_clients.py --live +python scripts/generate_test_clients.py --live --client urllib3 LIVE_HTTP_CLIENT=openapi-urllib3 LIVE_DPOP_ALGORITHM=ec \ - python -m pytest tests/live/test_mastercard_petstore.py -m live -v -s --tb=short -rs + python -m pytest tests/live/test_mastercard_petstore.py -v -s --tb=short -rs ``` ### Test failures @@ -354,19 +371,13 @@ the quality gates: ```bash python -m pip install -e ".[dev,generator]" -python scripts/run_tests.py +python scripts/generate_test_clients.py +python -m pytest -v python -m ruff check . python -m mypy python -m build ``` -`scripts/run_tests.py` regenerates all OpenAPI test clients before running -pytest. To regenerate without running tests: - -```bash -python scripts/generate_test_clients.py -``` - Tested Python, HTTP client and generator combinations are defined in the [CI workflows](.github/workflows). diff --git a/pyproject.toml b/pyproject.toml index a9a1669..7b25beb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,16 +50,8 @@ packages = ["src/mastercard_oauth2_client"] [tool.pytest.ini_options] testpaths = ["tests/unit", "tests/integration"] -addopts = "--strict-markers --strict-config" +addopts = "--strict-config" pythonpath = ["src", "tests/generated/openapi", "tests/generated/openapi_httpx", "tests/generated/openapi_asyncio"] -markers = [ - "aiohttp: aiohttp direct and generated-client integration tests", - "generated: OpenAPI-generated client tests", - "httpx: HTTPX direct and generated-client integration tests", - "live: Mastercard sandbox tests that require credentials", - "requests: Requests direct integration tests", - "urllib3: generated urllib3 client tests", -] [tool.ruff] target-version = "py312" diff --git a/scripts/generate_test_clients.py b/scripts/generate_test_clients.py index 60946cd..14c7ca9 100644 --- a/scripts/generate_test_clients.py +++ b/scripts/generate_test_clients.py @@ -2,6 +2,7 @@ import argparse import shutil +from collections.abc import Sequence from pathlib import Path import openapi_generator_cli @@ -9,35 +10,37 @@ ROOT = Path(__file__).resolve().parents[1] GENERATED_ROOT = ROOT / "tests" / "generated" TEST_SPECIFICATION = ROOT / "tests" / "resources" / "openapi" / "fake-api.yaml" -TEST_CLIENTS = ( - (GENERATED_ROOT / "openapi", "generated_test_client", "oauth2-generated-test-client", None), - ( +TEST_CLIENTS = { + "urllib3": (GENERATED_ROOT / "openapi", "generated_test_client", "oauth2-generated-test-client", None), + "httpx": ( GENERATED_ROOT / "openapi_httpx", "generated_httpx_test_client", "oauth2-generated-httpx-test-client", "httpx", ), - ( + "asyncio": ( GENERATED_ROOT / "openapi_asyncio", "generated_asyncio_test_client", "oauth2-generated-asyncio-test-client", "asyncio", ), -) +} LIVE_SPECIFICATION = ROOT / "tests" / "resources" / "openapi" / "petstore-api.yaml" -LIVE_CLIENTS = ( - (GENERATED_ROOT / "live_urllib3", "live_urllib3_client", "oauth2-live-urllib3-client", None), - (GENERATED_ROOT / "live_httpx", "live_httpx_client", "oauth2-live-httpx-client", "httpx"), - (GENERATED_ROOT / "live_asyncio", "live_asyncio_client", "oauth2-live-asyncio-client", "asyncio"), -) +LIVE_CLIENTS = { + "urllib3": (GENERATED_ROOT / "live_urllib3", "live_urllib3_client", "oauth2-live-urllib3-client", None), + "httpx": (GENERATED_ROOT / "live_httpx", "live_httpx_client", "oauth2-live-httpx-client", "httpx"), + "asyncio": (GENERATED_ROOT / "live_asyncio", "live_asyncio_client", "oauth2-live-asyncio-client", "asyncio"), +} -def main() -> None: +def main(arguments: Sequence[str] | None = None) -> None: parser = argparse.ArgumentParser(description="Generate OpenAPI clients used by tests") parser.add_argument("--live", action="store_true", help="generate Mastercard live-test clients") - arguments = parser.parse_args() - specification = LIVE_SPECIFICATION if arguments.live else TEST_SPECIFICATION - clients = LIVE_CLIENTS if arguments.live else TEST_CLIENTS + parser.add_argument("--client", choices=tuple(TEST_CLIENTS), help="generate only one client library") + options = parser.parse_args(arguments) + specification = LIVE_SPECIFICATION if options.live else TEST_SPECIFICATION + client_definitions = LIVE_CLIENTS if options.live else TEST_CLIENTS + clients = client_definitions.values() if options.client is None else (client_definitions[options.client],) if not specification.is_file(): raise SystemExit(f"OpenAPI specification not found: {specification}") diff --git a/scripts/run_tests.py b/scripts/run_tests.py deleted file mode 100644 index 2b470e8..0000000 --- a/scripts/run_tests.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -import subprocess -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] - - -def main() -> None: - subprocess.run([sys.executable, str(ROOT / "scripts" / "generate_test_clients.py")], check=True) - result = subprocess.run([sys.executable, "-m", "pytest", *sys.argv[1:]], cwd=ROOT, check=False) - raise SystemExit(result.returncode) - - -if __name__ == "__main__": - main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/direct/__init__.py b/tests/integration/direct/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/direct/aiohttp/__init__.py b/tests/integration/direct/aiohttp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_aiohttp.py b/tests/integration/direct/aiohttp/test_aiohttp_client.py similarity index 99% rename from tests/integration/test_aiohttp.py rename to tests/integration/direct/aiohttp/test_aiohttp_client.py index 1c56cb1..911085f 100644 --- a/tests/integration/test_aiohttp.py +++ b/tests/integration/direct/aiohttp/test_aiohttp_client.py @@ -22,8 +22,6 @@ from tests.integration.support.resource_server import PAYLOAD_ECHO_PATH from tests.integration.support.stores import InvalidatableTokenStore -pytestmark = pytest.mark.aiohttp - def _client( environment: OAuthTestEnvironment, diff --git a/tests/integration/direct/httpx/__init__.py b/tests/integration/direct/httpx/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_httpx_async.py b/tests/integration/direct/httpx/test_httpx_async_client.py similarity index 99% rename from tests/integration/test_httpx_async.py rename to tests/integration/direct/httpx/test_httpx_async_client.py index fd354ed..f8a0310 100644 --- a/tests/integration/test_httpx_async.py +++ b/tests/integration/direct/httpx/test_httpx_async_client.py @@ -14,8 +14,6 @@ from tests.integration.support.models import DPoPAlgorithm, RecordedRequest from tests.integration.support.stores import InvalidatableTokenStore -pytestmark = pytest.mark.httpx - def _client( environment: OAuthTestEnvironment, diff --git a/tests/integration/test_httpx.py b/tests/integration/direct/httpx/test_httpx_sync_client.py similarity index 99% rename from tests/integration/test_httpx.py rename to tests/integration/direct/httpx/test_httpx_sync_client.py index 61acbba..2797642 100644 --- a/tests/integration/test_httpx.py +++ b/tests/integration/direct/httpx/test_httpx_sync_client.py @@ -13,8 +13,6 @@ from tests.integration.support.models import DPoPAlgorithm from tests.integration.support.stores import InvalidatableTokenStore -pytestmark = pytest.mark.httpx - def _client( environment: OAuthTestEnvironment, diff --git a/tests/integration/direct/requests/__init__.py b/tests/integration/direct/requests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_requests.py b/tests/integration/direct/requests/test_requests_client.py similarity index 99% rename from tests/integration/test_requests.py rename to tests/integration/direct/requests/test_requests_client.py index 9318f4b..cb030cc 100644 --- a/tests/integration/test_requests.py +++ b/tests/integration/direct/requests/test_requests_client.py @@ -15,8 +15,6 @@ from tests.integration.support.resource_server import PAYLOAD_ECHO_PATH, REDIRECT_PATH from tests.integration.support.stores import InvalidatableTokenStore -pytestmark = pytest.mark.requests - def _client( environment: OAuthTestEnvironment, diff --git a/tests/integration/openapi/__init__.py b/tests/integration/openapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/openapi/asyncio/__init__.py b/tests/integration/openapi/asyncio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_openapi_asyncio.py b/tests/integration/openapi/asyncio/test_openapi_asyncio_client.py similarity index 99% rename from tests/integration/test_openapi_asyncio.py rename to tests/integration/openapi/asyncio/test_openapi_asyncio_client.py index f74eb13..f3fe344 100644 --- a/tests/integration/test_openapi_asyncio.py +++ b/tests/integration/openapi/asyncio/test_openapi_asyncio_client.py @@ -23,8 +23,6 @@ from tests.integration.support.environment import OAuthTestEnvironment from tests.integration.support.lifecycle import run_async_resource_lifecycle from tests.integration.support.lifecycle_clients.openapi_asyncio import OpenApiAsyncioLifecycleClient - -pytestmark = [pytest.mark.generated, pytest.mark.aiohttp] from tests.integration.support.models import DPoPAlgorithm from tests.integration.support.stores import InvalidatableTokenStore diff --git a/tests/integration/openapi/httpx/__init__.py b/tests/integration/openapi/httpx/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_openapi_httpx.py b/tests/integration/openapi/httpx/test_openapi_httpx_client.py similarity index 99% rename from tests/integration/test_openapi_httpx.py rename to tests/integration/openapi/httpx/test_openapi_httpx_client.py index 9a8a8db..3d8fc39 100644 --- a/tests/integration/test_openapi_httpx.py +++ b/tests/integration/openapi/httpx/test_openapi_httpx_client.py @@ -15,8 +15,6 @@ from tests.integration.support.models import DPoPAlgorithm from tests.integration.support.stores import InvalidatableTokenStore -pytestmark = [pytest.mark.generated, pytest.mark.httpx] - def _client( environment: OAuthTestEnvironment, diff --git a/tests/integration/openapi/urllib3/__init__.py b/tests/integration/openapi/urllib3/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_openapi.py b/tests/integration/openapi/urllib3/test_openapi_urllib3_client.py similarity index 99% rename from tests/integration/test_openapi.py rename to tests/integration/openapi/urllib3/test_openapi_urllib3_client.py index 105d89c..054e2e3 100644 --- a/tests/integration/test_openapi.py +++ b/tests/integration/openapi/urllib3/test_openapi_urllib3_client.py @@ -16,8 +16,6 @@ from tests.integration.support.models import DPoPAlgorithm from tests.integration.support.stores import InvalidatableTokenStore -pytestmark = [pytest.mark.generated, pytest.mark.urllib3] - def _client( environment: OAuthTestEnvironment, diff --git a/tests/live/test_mastercard_petstore.py b/tests/live/test_mastercard_petstore.py index 2b2ac79..62803fd 100644 --- a/tests/live/test_mastercard_petstore.py +++ b/tests/live/test_mastercard_petstore.py @@ -11,8 +11,6 @@ from .configuration import LiveConfigurationError, LiveSettings, build_oauth2_config, load_live_settings from .direct_clients import run_aiohttp, run_httpx, run_httpx_async, run_requests -pytestmark = pytest.mark.live - LiveRunner = Callable[[LiveSettings, OAuth2Config, str], None] _DIRECT_RUNNERS: dict[str, LiveRunner] = { "aiohttp": run_aiohttp, diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/direct/__init__.py b/tests/unit/direct/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/direct/aiohttp/__init__.py b/tests/unit/direct/aiohttp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_aiohttp_integration.py b/tests/unit/direct/aiohttp/test_aiohttp_middleware.py similarity index 99% rename from tests/unit/test_aiohttp_integration.py rename to tests/unit/direct/aiohttp/test_aiohttp_middleware.py index 1c4c1a0..ee2a5b7 100644 --- a/tests/unit/test_aiohttp_integration.py +++ b/tests/unit/direct/aiohttp/test_aiohttp_middleware.py @@ -6,7 +6,6 @@ from typing import Any, cast import aiohttp -import pytest from multidict import CIMultiDict from mastercard_oauth2_client.handlers import AsyncOAuth2Handler @@ -14,8 +13,6 @@ from mastercard_oauth2_client.models import HttpRequest from tests.unit.configuration import integration_config -pytestmark = pytest.mark.aiohttp - @dataclass class FakeResponse: diff --git a/tests/unit/direct/httpx/__init__.py b/tests/unit/direct/httpx/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_httpx_async_integration.py b/tests/unit/direct/httpx/test_httpx_async_transport.py similarity index 98% rename from tests/unit/test_httpx_async_integration.py rename to tests/unit/direct/httpx/test_httpx_async_transport.py index 020fd13..49a455f 100644 --- a/tests/unit/test_httpx_async_integration.py +++ b/tests/unit/direct/httpx/test_httpx_async_transport.py @@ -1,13 +1,10 @@ import asyncio import httpx -import pytest from mastercard_oauth2_client.integrations.httpx import AsyncOAuth2Transport from tests.unit.configuration import integration_config -pytestmark = pytest.mark.httpx - def test_async_httpx_transport_buffers_streaming_body_for_nonce_retry() -> None: async def exercise() -> None: diff --git a/tests/unit/test_httpx_integration.py b/tests/unit/direct/httpx/test_httpx_transport.py similarity index 97% rename from tests/unit/test_httpx_integration.py rename to tests/unit/direct/httpx/test_httpx_transport.py index 1fe8eef..885002a 100644 --- a/tests/unit/test_httpx_integration.py +++ b/tests/unit/direct/httpx/test_httpx_transport.py @@ -1,11 +1,8 @@ import httpx -import pytest from mastercard_oauth2_client.integrations.httpx import OAuth2Transport from tests.unit.configuration import integration_config -pytestmark = pytest.mark.httpx - def test_httpx_transport_buffers_streaming_body_for_nonce_retry() -> None: body = b'{"name":"streamed-resource"}' diff --git a/tests/unit/direct/requests/__init__.py b/tests/unit/direct/requests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_requests_integration.py b/tests/unit/direct/requests/test_requests_session.py similarity index 99% rename from tests/unit/test_requests_integration.py rename to tests/unit/direct/requests/test_requests_session.py index 3c752fb..1860256 100644 --- a/tests/unit/test_requests_integration.py +++ b/tests/unit/direct/requests/test_requests_session.py @@ -10,8 +10,6 @@ from mastercard_oauth2_client.integrations.requests import OAuth2Session from tests.unit.configuration import integration_config -pytestmark = pytest.mark.requests - class StubRequestsAdapter(requests.adapters.BaseAdapter): def __init__(self, responses: list[requests.Response] | None = None) -> None: diff --git a/tests/unit/openapi/__init__.py b/tests/unit/openapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/openapi/asyncio/__init__.py b/tests/unit/openapi/asyncio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_openapi_asyncio_adapter.py b/tests/unit/openapi/asyncio/test_openapi_asyncio_adapter.py similarity index 98% rename from tests/unit/test_openapi_asyncio_adapter.py rename to tests/unit/openapi/asyncio/test_openapi_asyncio_adapter.py index b17749b..82bcd14 100644 --- a/tests/unit/test_openapi_asyncio_adapter.py +++ b/tests/unit/openapi/asyncio/test_openapi_asyncio_adapter.py @@ -18,8 +18,6 @@ from mastercard_oauth2_client.integrations.aiohttp import OAuth2Middleware from mastercard_oauth2_client.integrations.openapi.asyncio import add_oauth2_layer -pytestmark = [pytest.mark.generated, pytest.mark.aiohttp] - @dataclass class Configuration: diff --git a/tests/unit/openapi/httpx/__init__.py b/tests/unit/openapi/httpx/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_openapi_httpx_adapter.py b/tests/unit/openapi/httpx/test_openapi_httpx_adapter.py similarity index 99% rename from tests/unit/test_openapi_httpx_adapter.py rename to tests/unit/openapi/httpx/test_openapi_httpx_adapter.py index 838c734..3ce25e3 100644 --- a/tests/unit/test_openapi_httpx_adapter.py +++ b/tests/unit/openapi/httpx/test_openapi_httpx_adapter.py @@ -28,8 +28,6 @@ ) from mastercard_oauth2_client.integrations.openapi.httpx import add_oauth2_layer -pytestmark = [pytest.mark.generated, pytest.mark.httpx] - @dataclass class RestClient: diff --git a/tests/unit/openapi/test_generate_test_clients.py b/tests/unit/openapi/test_generate_test_clients.py new file mode 100644 index 0000000..ae946d5 --- /dev/null +++ b/tests/unit/openapi/test_generate_test_clients.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from typing import Any + +from scripts import generate_test_clients + + +def test_generator_selects_one_deterministic_client(monkeypatch: Any) -> None: + generated: list[tuple[str, str]] = [] + monkeypatch.setattr( + generate_test_clients, + "_generate_client", + lambda specification, output, package, project, library: generated.append((package, library)), + ) + + generate_test_clients.main(["--client", "httpx"]) + + assert generated == [("generated_httpx_test_client", "httpx")] + + +def test_generator_selects_one_live_client(monkeypatch: Any) -> None: + generated: list[tuple[str, str]] = [] + monkeypatch.setattr( + generate_test_clients, + "_generate_client", + lambda specification, output, package, project, library: generated.append((package, library)), + ) + + generate_test_clients.main(["--live", "--client", "asyncio"]) + + assert generated == [("live_asyncio_client", "asyncio")] + + +def test_generator_defaults_to_all_clients(monkeypatch: Any) -> None: + generated: list[str] = [] + monkeypatch.setattr( + generate_test_clients, + "_generate_client", + lambda specification, output, package, project, library: generated.append(package), + ) + + generate_test_clients.main([]) + + assert generated == [ + "generated_test_client", + "generated_httpx_test_client", + "generated_asyncio_test_client", + ] \ No newline at end of file diff --git a/tests/unit/openapi/urllib3/__init__.py b/tests/unit/openapi/urllib3/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_openapi_adapter.py b/tests/unit/openapi/urllib3/test_openapi_urllib3_adapter.py similarity index 99% rename from tests/unit/test_openapi_adapter.py rename to tests/unit/openapi/urllib3/test_openapi_urllib3_adapter.py index 82c3213..d2674e9 100644 --- a/tests/unit/test_openapi_adapter.py +++ b/tests/unit/openapi/urllib3/test_openapi_urllib3_adapter.py @@ -14,8 +14,6 @@ from mastercard_oauth2_client.integrations.openapi.urllib3 import _generated_body from mastercard_oauth2_client.models import HttpRequest -pytestmark = [pytest.mark.generated, pytest.mark.urllib3] - @dataclass class GeneratedResponse: From 288307c651371ff7aebbded70e49e7285a5947b4 Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Wed, 23 Sep 2026 14:48:40 +0530 Subject: [PATCH 07/36] Remove temporary live test trigger --- .github/workflows/live-test.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/live-test.yml b/.github/workflows/live-test.yml index 12d058c..f1bb70e 100644 --- a/.github/workflows/live-test.yml +++ b/.github/workflows/live-test.yml @@ -4,9 +4,6 @@ on: workflow_dispatch: schedule: - cron: '0 6 * * 1' - push: - branches: - - feature/initial-implementation env: PYTHON_VERSION: '3.12' From f9c4f5a737f053f343a32620e89d07424f8466c5 Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Wed, 23 Sep 2026 16:31:41 +0530 Subject: [PATCH 08/36] Split compatibility workflows and add status badges --- .github/workflows/ci-aiohttp.yml | 11 +----- .github/workflows/ci-httpx.yml | 11 +----- .github/workflows/ci-openapi-asyncio.yml | 50 ++++++++++++++++++++++++ .github/workflows/ci-openapi-httpx.yml | 50 ++++++++++++++++++++++++ .github/workflows/ci-urllib3.yml | 4 +- README.md | 30 ++++++++------ 6 files changed, 123 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/ci-openapi-asyncio.yml create mode 100644 .github/workflows/ci-openapi-httpx.yml diff --git a/.github/workflows/ci-aiohttp.yml b/.github/workflows/ci-aiohttp.yml index a444e5d..25c90dc 100644 --- a/.github/workflows/ci-aiohttp.yml +++ b/.github/workflows/ci-aiohttp.yml @@ -37,7 +37,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install -e ".[dev,generator]" "${{ matrix.aiohttp }}" + python -m pip install -e ".[dev]" "${{ matrix.aiohttp }}" python -m pip check - name: Run direct aiohttp unit tests @@ -45,12 +45,3 @@ jobs: - name: Run direct aiohttp integration tests run: python -m pytest -v tests/integration/direct/aiohttp - - - name: Generate OpenAPI asyncio test client - run: python scripts/generate_test_clients.py --client asyncio - - - name: Run OpenAPI asyncio unit tests - run: python -m pytest -v tests/unit/openapi/asyncio - - - name: Run OpenAPI asyncio integration tests - run: python -m pytest -v tests/integration/openapi/asyncio diff --git a/.github/workflows/ci-httpx.yml b/.github/workflows/ci-httpx.yml index e12016f..ac76f41 100644 --- a/.github/workflows/ci-httpx.yml +++ b/.github/workflows/ci-httpx.yml @@ -37,7 +37,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install -e ".[dev,generator]" "${{ matrix.httpx }}" + python -m pip install -e ".[dev]" "${{ matrix.httpx }}" python -m pip check - name: Run direct HTTPX unit tests @@ -45,12 +45,3 @@ jobs: - name: Run direct HTTPX integration tests run: python -m pytest -v tests/integration/direct/httpx - - - name: Generate OpenAPI HTTPX test client - run: python scripts/generate_test_clients.py --client httpx - - - name: Run OpenAPI HTTPX unit tests - run: python -m pytest -v tests/unit/openapi/httpx - - - name: Run OpenAPI HTTPX integration tests - run: python -m pytest -v tests/integration/openapi/httpx diff --git a/.github/workflows/ci-openapi-asyncio.yml b/.github/workflows/ci-openapi-asyncio.yml new file mode 100644 index 0000000..ec78fde --- /dev/null +++ b/.github/workflows/ci-openapi-asyncio.yml @@ -0,0 +1,50 @@ +name: OpenAPI asyncio + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + tests: + name: OpenAPI asyncio ${{ matrix.label }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - label: minimum + aiohttp: 'aiohttp==3.13.0' + - label: latest + aiohttp: aiohttp + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev,generator]" "${{ matrix.aiohttp }}" + python -m pip check + + - name: Generate OpenAPI asyncio test client + run: python scripts/generate_test_clients.py --client asyncio + + - name: Run OpenAPI asyncio unit tests + run: python -m pytest -v tests/unit/openapi/asyncio + + - name: Run OpenAPI asyncio integration tests + run: python -m pytest -v tests/integration/openapi/asyncio diff --git a/.github/workflows/ci-openapi-httpx.yml b/.github/workflows/ci-openapi-httpx.yml new file mode 100644 index 0000000..faab798 --- /dev/null +++ b/.github/workflows/ci-openapi-httpx.yml @@ -0,0 +1,50 @@ +name: OpenAPI HTTPX + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + tests: + name: OpenAPI HTTPX ${{ matrix.label }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - label: minimum + httpx: 'httpx==0.28.0' + - label: latest + httpx: httpx + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev,generator]" "${{ matrix.httpx }}" + python -m pip check + + - name: Generate OpenAPI HTTPX test client + run: python scripts/generate_test_clients.py --client httpx + + - name: Run OpenAPI HTTPX unit tests + run: python -m pytest -v tests/unit/openapi/httpx + + - name: Run OpenAPI HTTPX integration tests + run: python -m pytest -v tests/integration/openapi/httpx diff --git a/.github/workflows/ci-urllib3.yml b/.github/workflows/ci-urllib3.yml index 1a830e6..ce444de 100644 --- a/.github/workflows/ci-urllib3.yml +++ b/.github/workflows/ci-urllib3.yml @@ -1,4 +1,4 @@ -name: urllib3 +name: OpenAPI urllib3 on: push: @@ -14,7 +14,7 @@ permissions: jobs: tests: - name: urllib3 ${{ matrix.label }} + name: OpenAPI urllib3 ${{ matrix.label }} runs-on: ubuntu-latest strategy: fail-fast: false diff --git a/README.md b/README.md index c75e6a1..d9bb392 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,9 @@ This project is licensed under the Apache License 2.0. ### Python -[![Python 3.12+](https://img.shields.io/badge/Python-3.12%2B-3776AB.svg?logo=python&logoColor=white)](.github/workflows/ci-python.yml) +| Versions | Status | +|---|---| +| 3.12, 3.13, 3.14 | [![Python](https://github.com/Mastercard/oauth2-client-python/actions/workflows/ci-python.yml/badge.svg)](https://github.com/Mastercard/oauth2-client-python/actions/workflows/ci-python.yml) | Python 3.12 or newer is required. @@ -109,11 +111,11 @@ For a higher-level experience, use an HTTP client integration to authenticate requests automatically. Choose the client that best fits your application. Each integration provides the same OAuth2 and DPoP behavior. -| HTTP client | Mode | Install | -|---|---|---| -| Requests | Sync | `mastercard-oauth2-client[requests]` | -| HTTPX | Sync and async | `mastercard-oauth2-client[httpx]` | -| aiohttp | Async | `mastercard-oauth2-client[aiohttp]` | +| HTTP client | Mode | Supported versions | Status | +|---|---|---|---| +| Requests | Sync | `>=2.33,<3` | [![Requests](https://github.com/Mastercard/oauth2-client-python/actions/workflows/ci-requests.yml/badge.svg)](https://github.com/Mastercard/oauth2-client-python/actions/workflows/ci-requests.yml) | +| HTTPX | Sync and async | `>=0.28,<1` | [![HTTPX](https://github.com/Mastercard/oauth2-client-python/actions/workflows/ci-httpx.yml/badge.svg)](https://github.com/Mastercard/oauth2-client-python/actions/workflows/ci-httpx.yml) | +| aiohttp | Async | `>=3.13,<4` | [![aiohttp](https://github.com/Mastercard/oauth2-client-python/actions/workflows/ci-aiohttp.yml/badge.svg)](https://github.com/Mastercard/oauth2-client-python/actions/workflows/ci-aiohttp.yml) | #### Requests @@ -184,11 +186,17 @@ Use normal `ClientSession` options for network settings. The library integrates with Python clients produced by OpenAPI Generator. Attach OAuth2 before the generated client sends its first request. -| Generated library | Mode | Install | -|---|---|---| -| Default (`urllib3`) | Sync | `mastercard-oauth2-client` | -| `httpx` | Async | `mastercard-oauth2-client[httpx]` | -| `asyncio` | Async | `mastercard-oauth2-client[aiohttp]` | +| Generated library | Mode | Supported versions | Status | +|---|---|---|---| +| Default (`urllib3`) | Sync | urllib3 `>=2.1,<3` | [![OpenAPI urllib3](https://github.com/Mastercard/oauth2-client-python/actions/workflows/ci-urllib3.yml/badge.svg)](https://github.com/Mastercard/oauth2-client-python/actions/workflows/ci-urllib3.yml) | +| `httpx` | Async | HTTPX `>=0.28,<1` | [![OpenAPI HTTPX](https://github.com/Mastercard/oauth2-client-python/actions/workflows/ci-openapi-httpx.yml/badge.svg)](https://github.com/Mastercard/oauth2-client-python/actions/workflows/ci-openapi-httpx.yml) | +| `asyncio` | Async | aiohttp `>=3.13,<4` | [![OpenAPI asyncio](https://github.com/Mastercard/oauth2-client-python/actions/workflows/ci-openapi-asyncio.yml/badge.svg)](https://github.com/Mastercard/oauth2-client-python/actions/workflows/ci-openapi-asyncio.yml) | + +#### OpenAPI Generator Compatibility + +| Versions | Status | +|---|---| +| 7.23, 7.24 | [![OpenAPI Generator](https://github.com/Mastercard/oauth2-client-python/actions/workflows/ci-openapi.yml/badge.svg)](https://github.com/Mastercard/oauth2-client-python/actions/workflows/ci-openapi.yml) | #### Python Generator (urllib3) From 0a7036455cb01596e205bdcfe06774b8b19aaae9 Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Mon, 28 Sep 2026 23:19:24 +0530 Subject: [PATCH 09/36] Review comments: Add key generators and separate default implementations --- README.md | 23 +++-- src/mastercard_oauth2_client/__init__.py | 7 +- src/mastercard_oauth2_client/defaults.py | 36 +++++++ src/mastercard_oauth2_client/keys.py | 52 +++++++--- src/mastercard_oauth2_client/protocols.py | 36 +------ tests/unit/test_defaults.py | 31 ++++++ tests/unit/test_jose_dpop.py | 7 -- tests/unit/test_keys.py | 117 ++++++++++++++++++---- tests/unit/test_protocols.py | 33 ------ 9 files changed, 226 insertions(+), 116 deletions(-) create mode 100644 src/mastercard_oauth2_client/defaults.py create mode 100644 tests/unit/test_defaults.py diff --git a/README.md b/README.md index d9bb392..59de7be 100644 --- a/README.md +++ b/README.md @@ -67,19 +67,26 @@ python -m pip install "mastercard-oauth2-client[requests]" `OAuth2Config` contains the client credentials, token endpoint, issuer, scopes and DPoP key configuration. Load your client signing key from PEM, DER or -PKCS#12 and your DPoP key pair from a private JWK: +PKCS#12. Load your DPoP key pair from a private JWK, or generate one for +sandbox testing: ```python from mastercard_oauth2_client import ( OAuth2Config, StaticDPoPKeyProvider, StaticScopeResolver, + generate_ec_key_pair, load_jwk_key_pair, load_private_key, ) client_key = load_private_key("path/to/client-private-key.pem") -dpop_key_pair = load_jwk_key_pair("path/to/dpop-private-key.json") + +# Generate a DPoP key pair for sandbox testing: +dpop_key_pair = generate_ec_key_pair() + +# Or load an existing detached DPoP key pair: +# dpop_key_pair = load_jwk_key_pair("path/to/dpop-private-key.json") oauth2_config = OAuth2Config( client_id="your-client-id", @@ -259,17 +266,19 @@ needs. - [`OAuth2Config`](src/mastercard_oauth2_client/config.py) connects credentials, endpoints and extension points. -- Use `StaticScopeResolver` for fixed scopes, or provide a custom +- Use [`StaticScopeResolver`](src/mastercard_oauth2_client/defaults.py) for + fixed scopes, or provide a custom [`ScopeResolver`](src/mastercard_oauth2_client/protocols.py) to choose scopes for each request. -- Use `StaticDPoPKeyProvider` for one key pair, or provide a custom +- Use [`StaticDPoPKeyProvider`](src/mastercard_oauth2_client/defaults.py) for + one key pair, or provide a custom [`DPoPKeyProvider`](src/mastercard_oauth2_client/protocols.py) to supply or rotate keys. - Use the default `InMemoryTokenStore`, or provide a custom [`TokenStore`](src/mastercard_oauth2_client/protocols.py) to control token storage. -- [Key loading](src/mastercard_oauth2_client/keys.py): loads PEM, DER, PKCS#12 - and private JWK key material. +- [Key utilities](src/mastercard_oauth2_client/keys.py): generate supported key + pairs and load PEM, DER, PKCS#12 and private JWK key material. ## Key Support @@ -277,6 +286,8 @@ The library supports RSA and EC keys: - Load client private keys from PEM, DER or PKCS#12 files. - Load DPoP key pairs from private JWKs. +- Generate RSA key pairs of at least 2048 bits with `generate_rsa_key_pair`. +- Generate P-256 key pairs for ES256 signing with `generate_ec_key_pair`. - Use RSA keys of at least 2048 bits for PS256 signing. - Use P-256 EC keys for ES256 signing. diff --git a/src/mastercard_oauth2_client/__init__.py b/src/mastercard_oauth2_client/__init__.py index 0d06c71..1ec5b4a 100644 --- a/src/mastercard_oauth2_client/__init__.py +++ b/src/mastercard_oauth2_client/__init__.py @@ -1,9 +1,12 @@ from .config import OAuth2Config, SecurityProfile +from .defaults import StaticDPoPKeyProvider, StaticScopeResolver from .dpop import create_client_assertion, create_resource_dpop_proof, create_token_dpop_proof from .exceptions import OAuth2ConfigError, OAuth2Error from .handlers import AsyncOAuth2Handler, OAuth2Handler from .integrations.openapi import add_oauth2_layer from .keys import ( + generate_ec_key_pair, + generate_rsa_key_pair, load_jwk_key_pair, load_pkcs12_private_key, load_private_key, @@ -15,8 +18,6 @@ AsyncHttpAdapter, DPoPKeyProvider, ScopeResolver, - StaticDPoPKeyProvider, - StaticScopeResolver, SyncHttpAdapter, TokenStore, ) @@ -51,6 +52,8 @@ "create_client_assertion", "create_resource_dpop_proof", "create_token_dpop_proof", + "generate_ec_key_pair", + "generate_rsa_key_pair", "load_jwk_key_pair", "load_pkcs12_private_key", "load_private_key", diff --git a/src/mastercard_oauth2_client/defaults.py b/src/mastercard_oauth2_client/defaults.py new file mode 100644 index 0000000..47c038f --- /dev/null +++ b/src/mastercard_oauth2_client/defaults.py @@ -0,0 +1,36 @@ +from collections.abc import Set as AbstractSet + +from ._internal._scopes import normalize_scopes +from ._internal.jose import jwk_thumbprint +from .models import DPoPKey, KeyPair + + +class StaticScopeResolver: + """Returns one fixed normalized scope set for every resource request.""" + + def __init__(self, scopes: AbstractSet[str]) -> None: + self._scopes = normalize_scopes(scopes) + + def resolve(self, method: str, url: str) -> frozenset[str]: + # Method and URL are intentionally ignored because this resolver is static. + return self._scopes + + def all_scopes(self) -> frozenset[str]: + return self._scopes + + +class StaticDPoPKeyProvider: + """Returns one fixed DPoP key when key rotation is not required.""" + + def __init__(self, key_pair: KeyPair) -> None: + self._dpop_key = DPoPKey( + key_id=jwk_thumbprint(key_pair.public_key), + key_pair=key_pair, + ) + + def get_current_key(self) -> DPoPKey: + return self._dpop_key + + def get_key(self, key_id: str) -> DPoPKey: + # The key ID is intentionally ignored because this provider has one key. + return self._dpop_key diff --git a/src/mastercard_oauth2_client/keys.py b/src/mastercard_oauth2_client/keys.py index ec49590..15ca570 100644 --- a/src/mastercard_oauth2_client/keys.py +++ b/src/mastercard_oauth2_client/keys.py @@ -16,7 +16,43 @@ type JwkSource = KeySource | Mapping[str, str | list[str]] _MINIMUM_RSA_KEY_SIZE = 2048 -_MINIMUM_EC_KEY_SIZE = 224 +_RSA_PUBLIC_EXPONENT = 65537 +_DEFAULT_EC_CURVE = ec.SECP256R1() + + +def _validate_rsa_key_size(key_size: object) -> None: + if isinstance(key_size, bool) or not isinstance(key_size, int): + raise OAuth2ConfigError("RSA key size must be an integer") + if key_size < _MINIMUM_RSA_KEY_SIZE: + raise OAuth2ConfigError( + f"RSA keys must have a minimum length of {_MINIMUM_RSA_KEY_SIZE} bits, " + f"but key length was: {key_size}" + ) + + +def _validate_ec_curve(curve: object) -> None: + if not isinstance(curve, ec.EllipticCurve): + raise OAuth2ConfigError("EC curve must be an elliptic curve") + if not isinstance(curve, ec.SECP256R1): + raise OAuth2ConfigError(f"EC keys must use P-256, but curve was: {curve.name}") + + +def generate_rsa_key_pair(key_size: int = _MINIMUM_RSA_KEY_SIZE) -> KeyPair: + """Generate an RSA key pair suitable for signing.""" + + _validate_rsa_key_size(key_size) + private_key = rsa.generate_private_key(public_exponent=_RSA_PUBLIC_EXPONENT, key_size=key_size) + validate_key(private_key) + return KeyPair(private_key=private_key, public_key=private_key.public_key()) + + +def generate_ec_key_pair(curve: ec.EllipticCurve = _DEFAULT_EC_CURVE) -> KeyPair: + """Generate a P-256 EC key pair for ES256 signing.""" + + _validate_ec_curve(curve) + private_key = ec.generate_private_key(curve) + validate_key(private_key) + return KeyPair(private_key=private_key, public_key=private_key.public_key()) def _read_source(source: KeySource) -> bytes: @@ -30,22 +66,14 @@ def _password_bytes(password: str | None) -> bytes | None: def validate_key(key: object) -> None: - """Validate an RSA or EC key against the supported FAPI minimums.""" + """Validate an RSA or EC key against the supported security profile.""" if isinstance(key, rsa.RSAPrivateKey | rsa.RSAPublicKey): - if key.key_size < _MINIMUM_RSA_KEY_SIZE: - raise OAuth2ConfigError( - f"RSA keys must have a minimum length of {_MINIMUM_RSA_KEY_SIZE} bits, " - f"but key length was: {key.key_size}" - ) + _validate_rsa_key_size(key.key_size) return if isinstance(key, ec.EllipticCurvePrivateKey | ec.EllipticCurvePublicKey): - if key.key_size < _MINIMUM_EC_KEY_SIZE: - raise OAuth2ConfigError( - f"Elliptic curve keys must have a minimum length of {_MINIMUM_EC_KEY_SIZE} bits, " - f"but key length was: {key.key_size}" - ) + _validate_ec_curve(key.curve) return algorithm = type(key).__name__ diff --git a/src/mastercard_oauth2_client/protocols.py b/src/mastercard_oauth2_client/protocols.py index 65f9de8..7e8b4e9 100644 --- a/src/mastercard_oauth2_client/protocols.py +++ b/src/mastercard_oauth2_client/protocols.py @@ -1,10 +1,7 @@ from collections.abc import Mapping -from collections.abc import Set as AbstractSet from typing import Protocol -from ._internal._scopes import normalize_scopes -from ._internal.jose import jwk_thumbprint -from .models import AccessToken, AccessTokenFilter, DPoPKey, HttpRequest, KeyPair +from .models import AccessToken, AccessTokenFilter, DPoPKey, HttpRequest class ScopeResolver(Protocol): @@ -83,34 +80,3 @@ def response_headers(self, response: ResponseT) -> Mapping[str, str]: ... async def response_body(self, response: ResponseT) -> bytes | str | None: ... async def close_response(self, response: ResponseT) -> None: ... - - -class StaticScopeResolver: - """Returns one fixed normalized scope set for every resource request.""" - - def __init__(self, scopes: AbstractSet[str]) -> None: - self._scopes = normalize_scopes(scopes) - - def resolve(self, method: str, url: str) -> frozenset[str]: - # Method and URL are intentionally ignored because this resolver is static. - return self._scopes - - def all_scopes(self) -> frozenset[str]: - return self._scopes - - -class StaticDPoPKeyProvider: - """Returns one fixed DPoP key when key rotation is not required.""" - - def __init__(self, key_pair: KeyPair) -> None: - self._dpop_key = DPoPKey( - key_id=jwk_thumbprint(key_pair.public_key), - key_pair=key_pair, - ) - - def get_current_key(self) -> DPoPKey: - return self._dpop_key - - def get_key(self, key_id: str) -> DPoPKey: - # The key ID is intentionally ignored because this provider has one key. - return self._dpop_key diff --git a/tests/unit/test_defaults.py b/tests/unit/test_defaults.py new file mode 100644 index 0000000..a26a4f4 --- /dev/null +++ b/tests/unit/test_defaults.py @@ -0,0 +1,31 @@ +from cryptography.hazmat.primitives.asymmetric import ec + +from mastercard_oauth2_client import DPoPKeyProvider, KeyPair, ScopeResolver +from mastercard_oauth2_client.defaults import StaticDPoPKeyProvider, StaticScopeResolver + + +def test_static_scope_resolver_satisfies_protocol_and_normalizes_scopes() -> None: + resolver: ScopeResolver = StaticScopeResolver({" payments:read ", "payments:write", "payments:read"}) + + assert resolver.resolve("GET", "https://api.example.com/payments") == frozenset( + {"payments:read", "payments:write"} + ) + assert resolver.all_scopes() == frozenset({"payments:read", "payments:write"}) + + +def test_static_scope_resolver_accepts_any_set_like_collection() -> None: + resolver = StaticScopeResolver({"payments:read": None}.keys()) + + assert resolver.all_scopes() == frozenset({"payments:read"}) + + +def test_static_dpop_key_provider_returns_current_and_named_key() -> None: + private_key = ec.generate_private_key(ec.SECP256R1()) + key_pair = KeyPair(private_key=private_key, public_key=private_key.public_key()) + provider: DPoPKeyProvider = StaticDPoPKeyProvider(key_pair) + dpop_key = provider.get_current_key() + + assert provider.get_current_key() is dpop_key + assert dpop_key.key_id + assert provider.get_key(dpop_key.key_id) is dpop_key + assert provider.get_key("unknown") is dpop_key \ No newline at end of file diff --git a/tests/unit/test_jose_dpop.py b/tests/unit/test_jose_dpop.py index de5b624..1971f8a 100644 --- a/tests/unit/test_jose_dpop.py +++ b/tests/unit/test_jose_dpop.py @@ -343,13 +343,6 @@ def test_public_jwk_rejects_unsupported_key_with_clear_message() -> None: jose_module.public_jwk(object()) # type: ignore[arg-type] -def test_client_assertion_rejects_non_p256_ec_key() -> None: - config = _config(ec.generate_private_key(ec.SECP384R1())) - - with pytest.raises(OAuth2ConfigError, match="Unsupported curve: secp384r1"): - create_client_assertion(config) - - def test_signing_rejects_unsupported_algorithm() -> None: private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) diff --git a/tests/unit/test_keys.py b/tests/unit/test_keys.py index f98c951..3d382d3 100644 --- a/tests/unit/test_keys.py +++ b/tests/unit/test_keys.py @@ -10,7 +10,13 @@ from cryptography.hazmat.primitives.serialization import pkcs12 from cryptography.x509.oid import NameOID -from mastercard_oauth2_client import DPoPKey, KeyPair, OAuth2ConfigError +from mastercard_oauth2_client import ( + DPoPKey, + KeyPair, + OAuth2ConfigError, + generate_ec_key_pair, + generate_rsa_key_pair, +) from mastercard_oauth2_client.keys import ( load_jwk_key_pair, load_pkcs12_private_key, @@ -40,6 +46,51 @@ def get_key(self, key_id: str) -> DPoPKey: return self._key +@pytest.mark.parametrize("key_size", [2048, 3072]) +def test_generate_rsa_key_pair_uses_requested_supported_size(key_size: int) -> None: + key_pair = generate_rsa_key_pair(key_size) + + assert isinstance(key_pair.private_key, rsa.RSAPrivateKey) + assert key_pair.private_key.key_size == key_size + assert key_pair.private_key.public_key().public_numbers().e == 65537 + assert key_pair.public_key.public_numbers() == key_pair.private_key.public_key().public_numbers() + + +def test_generate_rsa_key_pair_defaults_to_2048_bits() -> None: + assert generate_rsa_key_pair().private_key.key_size == 2048 + + +def test_generate_ec_key_pair_uses_p256_default_and_accepts_explicit_p256() -> None: + default_key_pair = generate_ec_key_pair() + explicit_key_pair = generate_ec_key_pair(ec.SECP256R1()) + + assert isinstance(default_key_pair.private_key.curve, ec.SECP256R1) + assert default_key_pair.public_key.public_numbers() == default_key_pair.private_key.public_key().public_numbers() + assert isinstance(explicit_key_pair.private_key.curve, ec.SECP256R1) + + +def test_generate_rsa_key_pair_rejects_size_below_supported_minimum() -> None: + with pytest.raises(OAuth2ConfigError, match="RSA keys must have a minimum length of 2048 bits"): + generate_rsa_key_pair(1024) + + +@pytest.mark.parametrize("key_size", [True, 2048.0, "2048", None]) +def test_generate_rsa_key_pair_rejects_non_integer_size(key_size: object) -> None: + with pytest.raises(OAuth2ConfigError, match="RSA key size must be an integer"): + generate_rsa_key_pair(key_size) # type: ignore[arg-type] + + +@pytest.mark.parametrize("curve", [ec.SECP192R1(), ec.SECP384R1()]) +def test_generate_ec_key_pair_rejects_unsupported_curve(curve: ec.EllipticCurve) -> None: + with pytest.raises(OAuth2ConfigError, match=f"EC keys must use P-256, but curve was: {curve.name}"): + generate_ec_key_pair(curve) + + +def test_generate_ec_key_pair_rejects_invalid_curve_type() -> None: + with pytest.raises(OAuth2ConfigError, match="EC curve must be an elliptic curve"): + generate_ec_key_pair(None) # type: ignore[arg-type] + + def test_validate_key_accepts_strong_rsa_and_ec_private_and_public_keys() -> None: rsa_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) ec_private_key = ec.generate_private_key(ec.SECP256R1()) @@ -60,14 +111,12 @@ def test_validate_key_rejects_weak_rsa_with_clear_message() -> None: validate_key(weak_key) -def test_validate_key_rejects_weak_ec_with_clear_message() -> None: - weak_key = ec.generate_private_key(ec.SECP192R1()) +@pytest.mark.parametrize("curve", [ec.SECP192R1(), ec.SECP384R1()]) +def test_validate_key_rejects_unsupported_ec_curve(curve: ec.EllipticCurve) -> None: + unsupported_key = ec.generate_private_key(curve) - with pytest.raises( - OAuth2ConfigError, - match="Elliptic curve keys must have a minimum length of 224 bits, but key length was: 192", - ): - validate_key(weak_key) + with pytest.raises(OAuth2ConfigError, match=f"EC keys must use P-256, but curve was: {curve.name}"): + validate_key(unsupported_key) def test_validate_key_rejects_unsupported_algorithm_with_clear_message() -> None: @@ -114,6 +163,18 @@ def test_load_private_key_supports_encrypted_pem_with_string_password() -> None: assert isinstance(loaded, ec.EllipticCurvePrivateKey) +def test_load_private_key_rejects_unsupported_ec_curve() -> None: + private_key = ec.generate_private_key(ec.SECP384R1()) + key_data = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + with pytest.raises(OAuth2ConfigError, match="EC keys must use P-256, but curve was: secp384r1"): + load_private_key(key_data) + + def test_load_private_key_accepts_a_file_path(tmp_path: Path) -> None: private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) key_path = tmp_path / "key.pem" @@ -172,6 +233,20 @@ def test_load_pkcs12_private_key_accepts_a_file_path(tmp_path: Path) -> None: assert loaded.private_numbers() == private_key.private_numbers() +def test_load_pkcs12_private_key_rejects_unsupported_ec_curve() -> None: + private_key = ec.generate_private_key(ec.SECP384R1()) + key_data = pkcs12.serialize_key_and_certificates( + name=b"key", + key=private_key, + cert=None, + cas=None, + encryption_algorithm=serialization.NoEncryption(), + ) + + with pytest.raises(OAuth2ConfigError, match="EC keys must use P-256, but curve was: secp384r1"): + load_pkcs12_private_key(key_data) + + @pytest.mark.parametrize("loader", [load_private_key, load_pkcs12_private_key, load_jwk_key_pair]) def test_key_loaders_propagate_missing_file_path(loader: Callable[[Path], object], tmp_path: Path) -> None: missing_path = tmp_path / "missing.key" @@ -215,36 +290,36 @@ def test_config_rejects_weak_client_key() -> None: OAuth2Config(**values) -def test_config_rejects_weak_ec_client_key() -> None: +def test_config_rejects_unsupported_ec_client_key() -> None: values = minimum_config_values() - values["client_key"] = ec.generate_private_key(ec.SECP192R1()) + values["client_key"] = ec.generate_private_key(ec.SECP384R1()) - with pytest.raises(OAuth2ConfigError, match="Elliptic curve keys must have a minimum length of 224 bits"): + with pytest.raises(OAuth2ConfigError, match="EC keys must use P-256, but curve was: secp384r1"): from mastercard_oauth2_client import OAuth2Config OAuth2Config(**values) -def test_config_rejects_weak_ec_dpop_key() -> None: +def test_config_rejects_unsupported_ec_dpop_key() -> None: from mastercard_oauth2_client import OAuth2Config - weak_private_key = ec.generate_private_key(ec.SECP192R1()) - weak_dpop_key = DPoPKey( - key_id="weak-key", - key_pair=KeyPair(private_key=weak_private_key, public_key=weak_private_key.public_key()), + unsupported_private_key = ec.generate_private_key(ec.SECP384R1()) + unsupported_dpop_key = DPoPKey( + key_id="unsupported-key", + key_pair=KeyPair(private_key=unsupported_private_key, public_key=unsupported_private_key.public_key()), ) - class WeakKeyProvider: + class UnsupportedKeyProvider: def get_current_key(self) -> DPoPKey: - return weak_dpop_key + return unsupported_dpop_key def get_key(self, key_id: str) -> DPoPKey: - return weak_dpop_key + return unsupported_dpop_key values = minimum_config_values() - values["dpop_key_provider"] = WeakKeyProvider() # type: ignore[typeddict-item] + values["dpop_key_provider"] = UnsupportedKeyProvider() # type: ignore[typeddict-item] - with pytest.raises(OAuth2ConfigError, match="Elliptic curve keys must have a minimum length of 224 bits"): + with pytest.raises(OAuth2ConfigError, match="EC keys must use P-256, but curve was: secp384r1"): OAuth2Config(**values) diff --git a/tests/unit/test_protocols.py b/tests/unit/test_protocols.py index 928bca0..8e7d04a 100644 --- a/tests/unit/test_protocols.py +++ b/tests/unit/test_protocols.py @@ -3,19 +3,13 @@ from datetime import UTC, datetime, timedelta import pytest -from cryptography.hazmat.primitives.asymmetric import ec from mastercard_oauth2_client import ( AccessToken, AccessTokenFilter, AsyncHttpAdapter, - DPoPKeyProvider, HttpRequest, InMemoryTokenStore, - KeyPair, - ScopeResolver, - StaticDPoPKeyProvider, - StaticScopeResolver, SyncHttpAdapter, TokenStore, ) @@ -87,33 +81,6 @@ async def close_response(self, response: object) -> None: pass -def test_static_scope_resolver_satisfies_protocol_and_normalizes_scopes() -> None: - resolver: ScopeResolver = StaticScopeResolver({" payments:read ", "payments:write", "payments:read"}) - - assert resolver.resolve("GET", "https://api.example.com/payments") == frozenset( - {"payments:read", "payments:write"} - ) - assert resolver.all_scopes() == frozenset({"payments:read", "payments:write"}) - - -def test_static_scope_resolver_accepts_any_set_like_collection() -> None: - resolver = StaticScopeResolver({"payments:read": None}.keys()) - - assert resolver.all_scopes() == frozenset({"payments:read"}) - - -def test_static_dpop_key_provider_returns_current_and_named_key() -> None: - private_key = ec.generate_private_key(ec.SECP256R1()) - key_pair = KeyPair(private_key=private_key, public_key=private_key.public_key()) - provider: DPoPKeyProvider = StaticDPoPKeyProvider(key_pair) - dpop_key = provider.get_current_key() - - assert provider.get_current_key() is dpop_key - assert dpop_key.key_id - assert provider.get_key(dpop_key.key_id) is dpop_key - assert provider.get_key("unknown") is dpop_key - - def test_sync_adapter_preserves_native_resource_types() -> None: adapter: SyncHttpAdapter[tuple[str, str], object] = NativeAdapter() request = ("GET", "https://api.example.com") From bbac4426c297f3666681a794f55d543d45330082 Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Tue, 29 Sep 2026 10:39:47 +0530 Subject: [PATCH 10/36] Review comments: Document public APIs --- src/mastercard_oauth2_client/config.py | 18 ++- src/mastercard_oauth2_client/defaults.py | 16 ++- src/mastercard_oauth2_client/dpop.py | 34 +++++- src/mastercard_oauth2_client/exceptions.py | 4 +- .../handlers/async_.py | 14 ++- src/mastercard_oauth2_client/handlers/sync.py | 14 ++- .../integrations/aiohttp.py | 2 + .../integrations/httpx.py | 4 + .../integrations/openapi/urllib3.py | 10 +- .../integrations/requests.py | 2 + src/mastercard_oauth2_client/keys.py | 80 +++++++++++-- src/mastercard_oauth2_client/models.py | 48 +++++++- src/mastercard_oauth2_client/protocols.py | 106 +++++++++++++----- src/mastercard_oauth2_client/store.py | 8 +- src/mastercard_oauth2_client/token.py | 27 ++++- 15 files changed, 328 insertions(+), 59 deletions(-) diff --git a/src/mastercard_oauth2_client/config.py b/src/mastercard_oauth2_client/config.py index 1078b0b..6347111 100644 --- a/src/mastercard_oauth2_client/config.py +++ b/src/mastercard_oauth2_client/config.py @@ -17,7 +17,7 @@ class SecurityProfile(Enum): - """OAuth2 security profiles supported by this library.""" + """OAuth2 security profiles supported by the client.""" FAPI2_PRIVATE_KEY_DPOP = "fapi2-private-key-dpop" @@ -40,7 +40,21 @@ def default_user_agent() -> str: @dataclass(frozen=True, slots=True) class OAuth2Config: - """Immutable inputs and extension points for the OAuth2 client.""" + """Immutable inputs and extension points for the OAuth2 client. + + Attributes: + client_id: OAuth2 client identifier. + token_endpoint: Absolute HTTPS token endpoint URL where token requests are sent. + issuer: Authorization server's unique identifier. + client_key: Supported private key used for client authentication via ``private_key_jwt``. + key_id: Key identifier for the client authentication key. + scope_resolver: Resolver that determines which scopes to request for each API call. + dpop_key_provider: Provider for supported key pairs used to generate DPoP proofs. + security_profile: OAuth2 security profile. Defaults to FAPI 2.0 private-key JWT with DPoP. + clock_skew_tolerance: Non-negative clock-skew tolerance used in assertion and proof timestamps. Defaults to 5 seconds. + token_store: Storage mechanism for caching access tokens. Defaults to in-memory storage. + user_agent: User-Agent header value. Defaults to the library-generated value. + """ client_id: str token_endpoint: str diff --git a/src/mastercard_oauth2_client/defaults.py b/src/mastercard_oauth2_client/defaults.py index 47c038f..da01c3d 100644 --- a/src/mastercard_oauth2_client/defaults.py +++ b/src/mastercard_oauth2_client/defaults.py @@ -6,31 +6,43 @@ class StaticScopeResolver: - """Returns one fixed normalized scope set for every resource request.""" + """Return one fixed normalized scope set for every resource request.""" def __init__(self, scopes: AbstractSet[str]) -> None: + """Create a resolver for the supplied scopes.""" + self._scopes = normalize_scopes(scopes) def resolve(self, method: str, url: str) -> frozenset[str]: + """Return the configured scopes for any HTTP method and URL.""" + # Method and URL are intentionally ignored because this resolver is static. return self._scopes def all_scopes(self) -> frozenset[str]: + """Return the complete configured scope set.""" + return self._scopes class StaticDPoPKeyProvider: - """Returns one fixed DPoP key when key rotation is not required.""" + """Return one fixed DPoP key when key rotation is not required.""" def __init__(self, key_pair: KeyPair) -> None: + """Create a provider for the supplied key pair.""" + self._dpop_key = DPoPKey( key_id=jwk_thumbprint(key_pair.public_key), key_pair=key_pair, ) def get_current_key(self) -> DPoPKey: + """Return the configured DPoP key.""" + return self._dpop_key def get_key(self, key_id: str) -> DPoPKey: + """Return the configured DPoP key for any key identifier.""" + # The key ID is intentionally ignored because this provider has one key. return self._dpop_key diff --git a/src/mastercard_oauth2_client/dpop.py b/src/mastercard_oauth2_client/dpop.py index d8598b5..c4c94a0 100644 --- a/src/mastercard_oauth2_client/dpop.py +++ b/src/mastercard_oauth2_client/dpop.py @@ -44,7 +44,14 @@ def _access_token_hash(access_token: str) -> str: def create_client_assertion(config: OAuth2Config) -> str: - """Create a private_key_jwt client assertion for the token endpoint.""" + """Create a signed ``private_key_jwt`` assertion for the token endpoint. + + Args: + config: OAuth2 client configuration containing the authentication key and claims. + + Returns: + Compact signed JWT client assertion. + """ return _create_client_assertion(config, now_provider=_utc_now, jti_provider=_random_jti) @@ -75,7 +82,16 @@ def create_token_dpop_proof( dpop_key_id: str, nonce: str | None = None, ) -> str: - """Create an RFC 9449 DPoP proof for a token endpoint request.""" + """Create an RFC 9449 DPoP proof for a token endpoint request. + + Args: + config: OAuth2 client configuration. + dpop_key_id: Identifier of the DPoP key used to sign the proof. + nonce: Server-provided DPoP nonce, if required. + + Returns: + Compact signed DPoP proof JWT. + """ return _create_dpop_proof( config, @@ -96,7 +112,19 @@ def create_resource_dpop_proof( access_token: str, nonce: str | None = None, ) -> str: - """Create an RFC 9449 DPoP proof bound to a resource request and token.""" + """Create an RFC 9449 DPoP proof bound to a resource request and access token. + + Args: + config: OAuth2 client configuration. + dpop_key_id: Identifier of the DPoP key used to sign the proof. + method: HTTP method of the resource request. + url: Absolute URL of the resource request. + access_token: Access token presented with the resource request. + nonce: Server-provided DPoP nonce, if required. + + Returns: + Compact signed DPoP proof JWT. + """ return _create_dpop_proof( config, diff --git a/src/mastercard_oauth2_client/exceptions.py b/src/mastercard_oauth2_client/exceptions.py index b92832d..f8c16fc 100644 --- a/src/mastercard_oauth2_client/exceptions.py +++ b/src/mastercard_oauth2_client/exceptions.py @@ -1,6 +1,6 @@ class OAuth2Error(Exception): - """Base exception for OAuth2 failures produced by this library.""" + """Report an OAuth2 operation failure produced by this library.""" class OAuth2ConfigError(OAuth2Error, ValueError): - """Raised when OAuth2 configuration is invalid.""" + """Report invalid OAuth2 configuration or unsupported key material.""" diff --git a/src/mastercard_oauth2_client/handlers/async_.py b/src/mastercard_oauth2_client/handlers/async_.py index 89b91f0..a770e92 100644 --- a/src/mastercard_oauth2_client/handlers/async_.py +++ b/src/mastercard_oauth2_client/handlers/async_.py @@ -24,14 +24,24 @@ class AsyncOAuth2Handler[RequestT, ResponseT]: - """Coordinate asynchronous token acquisition and DPoP resource requests.""" + """Authenticate asynchronous resource requests with OAuth2 and DPoP.""" def __init__(self, config: OAuth2Config) -> None: + """Create a handler using the supplied OAuth2 configuration.""" + self._config = config self._nonces = NonceStore() async def execute(self, request: RequestT, adapter: AsyncHttpAdapter[RequestT, ResponseT]) -> ResponseT: - """Authenticate and execute one resource request, retrying nonce challenges once.""" + """Authenticate and send one resource request. + + Args: + request: Resource request to authenticate and send. + adapter: Adapter for the request's HTTP client. + + Returns: + Resource response, or the token endpoint response if token acquisition fails. + """ method = adapter.request_method(request) url = adapter.request_url(request) diff --git a/src/mastercard_oauth2_client/handlers/sync.py b/src/mastercard_oauth2_client/handlers/sync.py index 78d18f0..000348e 100644 --- a/src/mastercard_oauth2_client/handlers/sync.py +++ b/src/mastercard_oauth2_client/handlers/sync.py @@ -24,14 +24,24 @@ class OAuth2Handler[RequestT, ResponseT]: - """Coordinate synchronous token acquisition and DPoP resource requests.""" + """Authenticate synchronous resource requests with OAuth2 and DPoP.""" def __init__(self, config: OAuth2Config) -> None: + """Create a handler using the supplied OAuth2 configuration.""" + self._config = config self._nonces = NonceStore() def execute(self, request: RequestT, adapter: SyncHttpAdapter[RequestT, ResponseT]) -> ResponseT: - """Authenticate and execute one resource request, retrying nonce challenges once.""" + """Authenticate and send one resource request. + + Args: + request: Resource request to authenticate and send. + adapter: Adapter for the request's HTTP client. + + Returns: + Resource response, or the token endpoint response if token acquisition fails. + """ method = adapter.request_method(request) url = adapter.request_url(request) diff --git a/src/mastercard_oauth2_client/integrations/aiohttp.py b/src/mastercard_oauth2_client/integrations/aiohttp.py index 1d48903..24aa9bd 100644 --- a/src/mastercard_oauth2_client/integrations/aiohttp.py +++ b/src/mastercard_oauth2_client/integrations/aiohttp.py @@ -65,6 +65,8 @@ class OAuth2Middleware: """Authenticate aiohttp client requests with OAuth2 and DPoP.""" def __init__(self, config: OAuth2Config) -> None: + """Create middleware using the supplied OAuth2 configuration.""" + self._handler = AsyncOAuth2Handler[aiohttp.ClientRequest, aiohttp.ClientResponse](config) async def __call__( diff --git a/src/mastercard_oauth2_client/integrations/httpx.py b/src/mastercard_oauth2_client/integrations/httpx.py index 65faf68..5f4a757 100644 --- a/src/mastercard_oauth2_client/integrations/httpx.py +++ b/src/mastercard_oauth2_client/integrations/httpx.py @@ -90,6 +90,8 @@ class OAuth2Transport(httpx.BaseTransport): """Authenticate requests while delegating network I/O to an HTTPX transport.""" def __init__(self, config: OAuth2Config, transport: httpx.BaseTransport | None = None) -> None: + """Create a transport using the OAuth2 configuration and optional base transport.""" + self._transport = transport or httpx.HTTPTransport() self._adapter = _SyncHttpxAdapter(self._transport) self._handler = OAuth2Handler[httpx.Request, httpx.Response](config) @@ -110,6 +112,8 @@ class AsyncOAuth2Transport(httpx.AsyncBaseTransport): """Authenticate async requests while delegating I/O to an HTTPX transport.""" def __init__(self, config: OAuth2Config, transport: httpx.AsyncBaseTransport | None = None) -> None: + """Create an async transport using the OAuth2 configuration and optional base transport.""" + self._transport = transport or httpx.AsyncHTTPTransport() self._adapter = _AsyncHttpxAdapter(self._transport) self._handler = AsyncOAuth2Handler[httpx.Request, httpx.Response](config) diff --git a/src/mastercard_oauth2_client/integrations/openapi/urllib3.py b/src/mastercard_oauth2_client/integrations/openapi/urllib3.py index d0a1738..9bf6b08 100644 --- a/src/mastercard_oauth2_client/integrations/openapi/urllib3.py +++ b/src/mastercard_oauth2_client/integrations/openapi/urllib3.py @@ -122,7 +122,15 @@ def close_response(self, response: _GeneratedResponse) -> None: def add_oauth2_layer(api_client: _ApiClient, config: OAuth2Config) -> None: - """Authenticate calls made by a synchronous generated OpenAPI Python client.""" + """Attach OAuth2 to a synchronous OpenAPI-generated urllib3 client. + + Args: + api_client: Generated API client whose REST request method will be wrapped. + config: OAuth2 client configuration. + + Raises: + OAuth2Error: If OAuth2 is already attached to the client. + """ original_request = api_client.rest_client.request if getattr(original_request, _OAUTH2_ATTACHED, False): diff --git a/src/mastercard_oauth2_client/integrations/requests.py b/src/mastercard_oauth2_client/integrations/requests.py index 74b7218..736a7f5 100644 --- a/src/mastercard_oauth2_client/integrations/requests.py +++ b/src/mastercard_oauth2_client/integrations/requests.py @@ -80,6 +80,8 @@ class OAuth2Session(requests.Session): """A Requests session that authenticates resource calls with OAuth2 and DPoP.""" def __init__(self, config: OAuth2Config) -> None: + """Create a session using the supplied OAuth2 configuration.""" + super().__init__() # Use a separate Requests session without OAuth resource handling. self._token_session = requests.Session() diff --git a/src/mastercard_oauth2_client/keys.py b/src/mastercard_oauth2_client/keys.py index 15ca570..7afd526 100644 --- a/src/mastercard_oauth2_client/keys.py +++ b/src/mastercard_oauth2_client/keys.py @@ -38,7 +38,17 @@ def _validate_ec_curve(curve: object) -> None: def generate_rsa_key_pair(key_size: int = _MINIMUM_RSA_KEY_SIZE) -> KeyPair: - """Generate an RSA key pair suitable for signing.""" + """Generate a supported RSA key pair. + + Args: + key_size: RSA modulus length in bits. Defaults to 2048. + + Returns: + The generated private and public keys. + + Raises: + OAuth2ConfigError: If the requested key size is invalid or unsupported. + """ _validate_rsa_key_size(key_size) private_key = rsa.generate_private_key(public_exponent=_RSA_PUBLIC_EXPONENT, key_size=key_size) @@ -47,7 +57,17 @@ def generate_rsa_key_pair(key_size: int = _MINIMUM_RSA_KEY_SIZE) -> KeyPair: def generate_ec_key_pair(curve: ec.EllipticCurve = _DEFAULT_EC_CURVE) -> KeyPair: - """Generate a P-256 EC key pair for ES256 signing.""" + """Generate a supported EC key pair. + + Args: + curve: Elliptic curve to use. Defaults to P-256. + + Returns: + The generated private and public keys. + + Raises: + OAuth2ConfigError: If the requested curve is invalid or unsupported. + """ _validate_ec_curve(curve) private_key = ec.generate_private_key(curve) @@ -66,7 +86,14 @@ def _password_bytes(password: str | None) -> bytes | None: def validate_key(key: object) -> None: - """Validate an RSA or EC key against the supported security profile.""" + """Validate a key against the supported security profile. + + Args: + key: RSA or EC private or public key to validate. + + Raises: + OAuth2ConfigError: If the key type or parameters are unsupported. + """ if isinstance(key, rsa.RSAPrivateKey | rsa.RSAPublicKey): _validate_rsa_key_size(key.key_size) @@ -90,7 +117,14 @@ def validate_private_key(key: object, *, context: str = "Key") -> PrivateKey: def validate_dpop_key(dpop_key: object) -> None: - """Validate the current DPoP key returned by a provider.""" + """Validate a DPoP key returned by a provider. + + Args: + dpop_key: DPoP key to validate. + + Raises: + OAuth2ConfigError: If the key ID, key pair, algorithm, or key strength is invalid. + """ if not isinstance(dpop_key, DPoPKey) or dpop_key.key_pair is None: raise OAuth2ConfigError("DPoP key provider must return a valid DPoP key") @@ -101,7 +135,18 @@ def validate_dpop_key(dpop_key: object) -> None: def load_private_key(source: KeySource, password: str | None = None) -> PrivateKey: - """Load and validate an RSA or EC private key from PEM or DER data.""" + """Load and validate a supported private key from PEM or DER data. + + Args: + source: File path or encoded key bytes. + password: Password for an encrypted key, if required. + + Returns: + The loaded private key. + + Raises: + OAuth2ConfigError: If the key cannot be loaded or is not supported. + """ key_data = _read_source(source) password_bytes = _password_bytes(password) @@ -118,7 +163,18 @@ def load_private_key(source: KeySource, password: str | None = None) -> PrivateK def load_pkcs12_private_key(source: KeySource, password: str | None = None) -> PrivateKey: - """Load and validate the private key contained in PKCS#12 data.""" + """Load and validate a supported private key from PKCS#12 data. + + Args: + source: File path or encoded PKCS#12 bytes. + password: Password for the PKCS#12 data, if required. + + Returns: + The private key contained in the PKCS#12 data. + + Raises: + OAuth2ConfigError: If the data cannot be loaded, contains no private key, or uses an unsupported key. + """ key_data = _read_source(source) password_bytes = _password_bytes(password) @@ -135,7 +191,17 @@ def load_pkcs12_private_key(source: KeySource, password: str | None = None) -> P def load_jwk_key_pair(source: JwkSource) -> KeyPair: - """Parse an RFC 7517 RSA or EC private JWK and construct its key pair.""" + """Load a supported key pair from an RFC 7517 private JWK. + + Args: + source: File path, encoded JSON bytes, or JWK mapping. + + Returns: + The private and public keys represented by the JWK. + + Raises: + OAuth2ConfigError: If the JWK is invalid, lacks private key material, or uses an unsupported key. + """ try: value = dict(source) if isinstance(source, Mapping) else json.loads(_read_source(source)) diff --git a/src/mastercard_oauth2_client/models.py b/src/mastercard_oauth2_client/models.py index c6fd2b2..25d12c6 100644 --- a/src/mastercard_oauth2_client/models.py +++ b/src/mastercard_oauth2_client/models.py @@ -10,7 +10,12 @@ @dataclass(frozen=True, slots=True) class KeyPair: - """An asymmetric private/public key pair used for DPoP signing.""" + """Hold an asymmetric key pair used for DPoP signing. + + Attributes: + private_key: Supported private key used to sign DPoP proofs. + public_key: Public key corresponding to ``private_key``. + """ private_key: PrivateKey public_key: PublicKey @@ -18,7 +23,12 @@ class KeyPair: @dataclass(frozen=True, slots=True) class DPoPKey: - """A DPoP signing key pair and its stable key identifier.""" + """Identify a key pair used to sign DPoP proofs. + + Attributes: + key_id: Stable identifier used to retrieve the key for proof generation. + key_pair: Supported key pair used to sign DPoP proofs. + """ key_id: str key_pair: KeyPair @@ -26,7 +36,15 @@ class DPoPKey: @dataclass(frozen=True, slots=True) class AccessToken: - """A cached access token and the metadata required for safe reuse.""" + """Store an access token and the metadata required for safe reuse. + + Attributes: + client_id: OAuth2 client identifier associated with the token. + token_value: Access token value returned by the authorization server. + scopes: Scopes granted to the access token. + expires_at: Absolute UTC expiration time. + jkt: JWK thumbprint of the DPoP key bound to the token, if present. + """ client_id: str token_value: str @@ -37,7 +55,12 @@ class AccessToken: @dataclass(frozen=True, slots=True) class AccessTokenFilter: - """Criteria used together to find a reusable access token.""" + """Describe the criteria for finding a reusable access token. + + Attributes: + scopes: Exact scopes required for the access token. + jkt: Required DPoP key thumbprint, or ``None`` for a scope-only lookup. + """ scopes: frozenset[str] jkt: str | None = None @@ -45,7 +68,14 @@ class AccessTokenFilter: @dataclass(frozen=True, slots=True) class HttpRequest: - """HTTP-client-neutral token-endpoint request.""" + """Describe a token-endpoint request without depending on an HTTP client. + + Attributes: + method: HTTP request method. + url: Absolute token endpoint URL. + headers: Headers to send with the request. + body: Encoded request body, if present. + """ method: str url: str @@ -55,7 +85,13 @@ class HttpRequest: @dataclass(frozen=True, slots=True) class HttpResponse: - """HTTP-client-neutral token-endpoint response snapshot.""" + """Capture a token-endpoint response without depending on an HTTP client. + + Attributes: + status: HTTP response status code. + headers: Response headers. + body: Response body, if present. + """ status: int headers: Mapping[str, str] diff --git a/src/mastercard_oauth2_client/protocols.py b/src/mastercard_oauth2_client/protocols.py index 7e8b4e9..938d2d0 100644 --- a/src/mastercard_oauth2_client/protocols.py +++ b/src/mastercard_oauth2_client/protocols.py @@ -5,78 +5,126 @@ class ScopeResolver(Protocol): - """Determines the OAuth2 scopes required for a resource request.""" + """Determine which OAuth2 scopes to request for API calls.""" - def resolve(self, method: str, url: str) -> frozenset[str]: ... + def resolve(self, method: str, url: str) -> frozenset[str]: + """Return the scopes required for one HTTP method and URL.""" + ... - def all_scopes(self) -> frozenset[str]: ... + def all_scopes(self) -> frozenset[str]: + """Return every scope this resolver can request.""" + ... class TokenStore(Protocol): - """Stores reusable access tokens for one OAuth2 client configuration.""" + """Store and retrieve reusable access tokens for one OAuth2 client.""" - def put(self, access_token: AccessToken) -> None: ... + def put(self, access_token: AccessToken) -> None: + """Store an access token for later reuse.""" + ... - def get(self, token_filter: AccessTokenFilter) -> AccessToken | None: ... + def get(self, token_filter: AccessTokenFilter) -> AccessToken | None: + """Return a matching reusable token, or ``None`` when none is available.""" + ... class DPoPKeyProvider(Protocol): - """Supplies the current DPoP key and resolves keys by identifier.""" + """Supply DPoP signing keys for proof generation.""" - def get_current_key(self) -> DPoPKey: ... + def get_current_key(self) -> DPoPKey: + """Return the key used for new token and resource requests.""" + ... - def get_key(self, key_id: str) -> DPoPKey: ... + def get_key(self, key_id: str) -> DPoPKey: + """Return the key identified by ``key_id``.""" + ... class SyncHttpAdapter[RequestT, ResponseT](Protocol): - """Bridge OAuth2 orchestration to native resource request and response types. + """Connect OAuth2 orchestration to a synchronous HTTP client. Response inspection must preserve any final response returned to the caller. The handler closes only token responses it consumes and challenged resource responses it replaces with a retry. """ - def request_method(self, request: RequestT) -> str: ... + def request_method(self, request: RequestT) -> str: + """Return the native request's HTTP method.""" + ... - def request_url(self, request: RequestT) -> str: ... + def request_url(self, request: RequestT) -> str: + """Return the native request's absolute URL.""" + ... - def request_headers(self, request: RequestT) -> Mapping[str, str]: ... + def request_headers(self, request: RequestT) -> Mapping[str, str]: + """Return the native request's current headers.""" + ... - def send_token_request(self, resource_request: RequestT, request: HttpRequest) -> ResponseT: ... + def send_token_request(self, resource_request: RequestT, request: HttpRequest) -> ResponseT: + """Send a token request using the resource request's client context.""" + ... - def send_resource_request(self, request: RequestT, headers: Mapping[str, str]) -> ResponseT: ... + def send_resource_request(self, request: RequestT, headers: Mapping[str, str]) -> ResponseT: + """Send the native resource request with authenticated headers.""" + ... - def response_status(self, response: ResponseT) -> int: ... + def response_status(self, response: ResponseT) -> int: + """Return the native response's HTTP status code.""" + ... - def response_headers(self, response: ResponseT) -> Mapping[str, str]: ... + def response_headers(self, response: ResponseT) -> Mapping[str, str]: + """Return the native response's headers.""" + ... - def response_body(self, response: ResponseT) -> bytes | str | None: ... + def response_body(self, response: ResponseT) -> bytes | str | None: + """Read and return the native response body.""" + ... - def close_response(self, response: ResponseT) -> None: ... + def close_response(self, response: ResponseT) -> None: + """Close a response consumed or replaced by the OAuth2 handler.""" + ... class AsyncHttpAdapter[RequestT, ResponseT](Protocol): - """Bridge OAuth2 orchestration to asynchronous native HTTP client types. + """Connect OAuth2 orchestration to an asynchronous HTTP client. Response inspection must preserve any final response returned to the caller. The handler closes only token responses it consumes and challenged resource responses it replaces with a retry. """ - def request_method(self, request: RequestT) -> str: ... + def request_method(self, request: RequestT) -> str: + """Return the native request's HTTP method.""" + ... - def request_url(self, request: RequestT) -> str: ... + def request_url(self, request: RequestT) -> str: + """Return the native request's absolute URL.""" + ... - def request_headers(self, request: RequestT) -> Mapping[str, str]: ... + def request_headers(self, request: RequestT) -> Mapping[str, str]: + """Return the native request's current headers.""" + ... - async def send_token_request(self, resource_request: RequestT, request: HttpRequest) -> ResponseT: ... + async def send_token_request(self, resource_request: RequestT, request: HttpRequest) -> ResponseT: + """Send a token request using the resource request's client context.""" + ... - async def send_resource_request(self, request: RequestT, headers: Mapping[str, str]) -> ResponseT: ... + async def send_resource_request(self, request: RequestT, headers: Mapping[str, str]) -> ResponseT: + """Send the native resource request with authenticated headers.""" + ... - def response_status(self, response: ResponseT) -> int: ... + def response_status(self, response: ResponseT) -> int: + """Return the native response's HTTP status code.""" + ... - def response_headers(self, response: ResponseT) -> Mapping[str, str]: ... + def response_headers(self, response: ResponseT) -> Mapping[str, str]: + """Return the native response's headers.""" + ... - async def response_body(self, response: ResponseT) -> bytes | str | None: ... + async def response_body(self, response: ResponseT) -> bytes | str | None: + """Read and return the native response body.""" + ... - async def close_response(self, response: ResponseT) -> None: ... + async def close_response(self, response: ResponseT) -> None: + """Close a response consumed or replaced by the OAuth2 handler.""" + ... diff --git a/src/mastercard_oauth2_client/store.py b/src/mastercard_oauth2_client/store.py index 2b5f376..30b3946 100644 --- a/src/mastercard_oauth2_client/store.py +++ b/src/mastercard_oauth2_client/store.py @@ -9,7 +9,7 @@ class InMemoryTokenStore: - """Thread-safe token cache for one OAuth2 client configuration. + """Cache access tokens in memory for one OAuth2 client. A DPoP-bound token can be retrieved by scopes alone or by scopes and its JWK thumbprint. Use a separate instance for each configured OAuth2 client. @@ -18,12 +18,16 @@ class InMemoryTokenStore: EARLY_EXPIRATION_THRESHOLD = timedelta(seconds=60) def __init__(self, now_provider: NowProvider = _utc_now) -> None: + """Create a token store using the supplied clock.""" + self._now = now_provider self._tokens: dict[TokenCacheKey, AccessToken] = {} # Keep each lookup or update atomic. self._lock = RLock() def put(self, access_token: AccessToken) -> None: + """Store an access token by its scopes and optional DPoP binding.""" + with self._lock: self._remove_expired_tokens() @@ -35,6 +39,8 @@ def put(self, access_token: AccessToken) -> None: self._tokens[dpop_bound_key] = access_token def get(self, token_filter: AccessTokenFilter) -> AccessToken | None: + """Return a matching token that remains usable for at least 60 seconds.""" + key = self._create_key(token_filter.jkt, token_filter.scopes) with self._lock: access_token = self._tokens.get(key) diff --git a/src/mastercard_oauth2_client/token.py b/src/mastercard_oauth2_client/token.py index 21a8c01..b74d3f6 100644 --- a/src/mastercard_oauth2_client/token.py +++ b/src/mastercard_oauth2_client/token.py @@ -59,7 +59,17 @@ def build_token_request( dpop_key_id: str, nonce: str | None = None, ) -> HttpRequest: - """Build a neutral RFC 6749 client-credentials request using DPoP.""" + """Build an RFC 6749 client-credentials token request using DPoP. + + Args: + config: OAuth2 client configuration. + scopes: Scopes to request from the authorization server. + dpop_key_id: Identifier of the DPoP key used to sign the proof. + nonce: Server-provided DPoP nonce, if required. + + Returns: + HTTP-client-neutral token request ready for an adapter to send. + """ return HttpRequest( method="POST", @@ -120,7 +130,20 @@ def parse_token_response( requested_scopes: AbstractSet[str], dpop_key_id: str, ) -> AccessToken: - """Parse a successful RFC 6749 response into a cache-ready DPoP token.""" + """Parse and validate an RFC 6749 DPoP token response. + + Args: + config: OAuth2 client configuration associated with the request. + response: HTTP-client-neutral token response snapshot. + requested_scopes: Scopes requested from the authorization server. + dpop_key_id: Identifier of the DPoP key bound to the token request. + + Returns: + Access token with expiration, scopes, and DPoP binding metadata. + + Raises: + OAuth2Error: If the response is unsuccessful, malformed, or not a valid DPoP token response. + """ return _parse_token_response( config, From 1d18291e7f3c7c03bb1fb5bac00d846850ee0e3f Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Tue, 29 Sep 2026 11:17:17 +0530 Subject: [PATCH 11/36] Review comments: Use ASCII encoding for DPoP access token hash (ath) --- src/mastercard_oauth2_client/dpop.py | 2 +- tests/unit/test_jose_dpop.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/mastercard_oauth2_client/dpop.py b/src/mastercard_oauth2_client/dpop.py index c4c94a0..35e3324 100644 --- a/src/mastercard_oauth2_client/dpop.py +++ b/src/mastercard_oauth2_client/dpop.py @@ -39,7 +39,7 @@ def _canonicalize_htu(url: str) -> str: def _access_token_hash(access_token: str) -> str: """Compute the RFC 9449 section 4.2 ath claim value.""" - digest = hashlib.sha256(access_token.encode("utf-8")).digest() + digest = hashlib.sha256(access_token.encode("ascii")).digest() return _base64url_encode(digest) diff --git a/tests/unit/test_jose_dpop.py b/tests/unit/test_jose_dpop.py index 1971f8a..10871c8 100644 --- a/tests/unit/test_jose_dpop.py +++ b/tests/unit/test_jose_dpop.py @@ -236,6 +236,20 @@ def test_resource_dpop_proof_binds_method_url_and_access_token(fixed_proof_value assert claims["ath"] == base64.urlsafe_b64encode(hashlib.sha256(b"access-token").digest()).rstrip(b"=").decode() +def test_resource_dpop_proof_rejects_non_ascii_access_token() -> None: + config = _config(rsa.generate_private_key(public_exponent=65537, key_size=2048)) + dpop_key = config.dpop_key_provider.get_current_key() + + with pytest.raises(UnicodeEncodeError): + create_resource_dpop_proof( + config, + dpop_key.key_id, + "GET", + "https://api.example.com/payments", + "non-ascii-token-\u00f6", + ) + + def test_static_dpop_provider_accepts_key_pair_and_uses_thumbprint_id() -> None: private_key = ec.generate_private_key(ec.SECP256R1()) provider = StaticDPoPKeyProvider(KeyPair(private_key=private_key, public_key=private_key.public_key())) From c753231f8e2287687831dc316869650af85580d4 Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Tue, 29 Sep 2026 11:48:38 +0530 Subject: [PATCH 12/36] Review comments: Document intentional JOSE dependency decision --- src/mastercard_oauth2_client/_internal/jose.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/mastercard_oauth2_client/_internal/jose.py b/src/mastercard_oauth2_client/_internal/jose.py index 90d16b0..7b30145 100644 --- a/src/mastercard_oauth2_client/_internal/jose.py +++ b/src/mastercard_oauth2_client/_internal/jose.py @@ -1,3 +1,10 @@ +"""JOSE support for this library's FAPI and DPoP flows. + +Cryptographic operations use ``cryptography``. This module intentionally +implements only the PS256, ES256, JWK, and JWT behavior the library needs, +without adding an external JOSE dependency. +""" + import base64 import hashlib import json From 0a5d681c2f5bbb9ce0ad39e53d2319d981949023 Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Tue, 29 Sep 2026 13:46:34 +0530 Subject: [PATCH 13/36] Review comments: Document token request middleware behavior --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 59de7be..1301a97 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,10 @@ OAuth2 and DPoP flow. To support another HTTP client, provide the matching and pass it to the handler. For higher-level use, choose a ready-made integration below. +Token requests bypass resource authentication, hooks and middleware so that +OAuth2 does not try to authenticate its own token call. Integrations still reuse +the client's relevant network and timeout settings. + ### Direct HTTP Client Integration For a higher-level experience, use an HTTP client integration to authenticate From aa6056ae342780cfa87a21eea8ef2ca7afe8f9f3 Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Tue, 29 Sep 2026 17:24:51 +0530 Subject: [PATCH 14/36] Review comments: Preserve repeated response headers in HTTP adapters --- .../_internal/orchestration.py | 6 +++-- .../integrations/openapi/urllib3.py | 6 +---- src/mastercard_oauth2_client/protocols.py | 14 +++++------ .../direct/aiohttp/test_aiohttp_middleware.py | 20 ++++++++++++++++ .../urllib3/test_openapi_urllib3_adapter.py | 23 +++++++++++++++++++ tests/unit/test_orchestration.py | 14 ++++++++++- 6 files changed, 67 insertions(+), 16 deletions(-) diff --git a/src/mastercard_oauth2_client/_internal/orchestration.py b/src/mastercard_oauth2_client/_internal/orchestration.py index a238e72..d831905 100644 --- a/src/mastercard_oauth2_client/_internal/orchestration.py +++ b/src/mastercard_oauth2_client/_internal/orchestration.py @@ -78,8 +78,10 @@ def build_resource_headers( def has_nonce_challenge_header(status: int, headers: Mapping[str, str]) -> bool: if not is_nonce_challenge_status(status): return False - authenticate = get_header(headers, _WWW_AUTHENTICATE_HEADER) - return authenticate is not None and _USE_DPOP_NONCE in authenticate.casefold() + return any( + name.casefold() == _WWW_AUTHENTICATE_HEADER.casefold() and _USE_DPOP_NONCE in value.casefold() + for name, value in headers.items() + ) def has_nonce_challenge_body(status: int, body: bytes | str | None) -> bool: diff --git a/src/mastercard_oauth2_client/integrations/openapi/urllib3.py b/src/mastercard_oauth2_client/integrations/openapi/urllib3.py index 9bf6b08..60b88c5 100644 --- a/src/mastercard_oauth2_client/integrations/openapi/urllib3.py +++ b/src/mastercard_oauth2_client/integrations/openapi/urllib3.py @@ -111,7 +111,7 @@ def response_status(self, response: _GeneratedResponse) -> int: return response.status def response_headers(self, response: _GeneratedResponse) -> Mapping[str, str]: - return _response_headers(response) + return response.getheaders() def response_body(self, response: _GeneratedResponse) -> bytes | str | None: return response.read() @@ -165,10 +165,6 @@ def _url_with_query(url: str, query_params: object) -> str: return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, query, parsed.fragment)) -def _response_headers(response: _GeneratedResponse) -> dict[str, str]: - return {str(name).casefold(): str(value) for name, value in response.getheaders().items()} - - def _generated_body(request: HttpRequest) -> tuple[object | None, list[tuple[str, str]] | None]: content_type = _content_type(request.headers) if content_type == "application/x-www-form-urlencoded" and isinstance(request.body, str): diff --git a/src/mastercard_oauth2_client/protocols.py b/src/mastercard_oauth2_client/protocols.py index 938d2d0..734a491 100644 --- a/src/mastercard_oauth2_client/protocols.py +++ b/src/mastercard_oauth2_client/protocols.py @@ -43,9 +43,8 @@ def get_key(self, key_id: str) -> DPoPKey: class SyncHttpAdapter[RequestT, ResponseT](Protocol): """Connect OAuth2 orchestration to a synchronous HTTP client. - Response inspection must preserve any final response returned to the caller. - The handler closes only token responses it consumes and challenged resource - responses it replaces with a retry. + Do not close or consume a response that will be returned to the caller. The + handler closes only token responses and resource responses replaced by a retry. """ def request_method(self, request: RequestT) -> str: @@ -73,7 +72,7 @@ def response_status(self, response: ResponseT) -> int: ... def response_headers(self, response: ResponseT) -> Mapping[str, str]: - """Return the native response's headers.""" + """Return the native response headers without discarding repeated values.""" ... def response_body(self, response: ResponseT) -> bytes | str | None: @@ -88,9 +87,8 @@ def close_response(self, response: ResponseT) -> None: class AsyncHttpAdapter[RequestT, ResponseT](Protocol): """Connect OAuth2 orchestration to an asynchronous HTTP client. - Response inspection must preserve any final response returned to the caller. - The handler closes only token responses it consumes and challenged resource - responses it replaces with a retry. + Do not close or consume a response that will be returned to the caller. The + handler closes only token responses and resource responses replaced by a retry. """ def request_method(self, request: RequestT) -> str: @@ -118,7 +116,7 @@ def response_status(self, response: ResponseT) -> int: ... def response_headers(self, response: ResponseT) -> Mapping[str, str]: - """Return the native response's headers.""" + """Return the native response headers without discarding repeated values.""" ... async def response_body(self, response: ResponseT) -> bytes | str | None: diff --git a/tests/unit/direct/aiohttp/test_aiohttp_middleware.py b/tests/unit/direct/aiohttp/test_aiohttp_middleware.py index ee2a5b7..5fd24fb 100644 --- a/tests/unit/direct/aiohttp/test_aiohttp_middleware.py +++ b/tests/unit/direct/aiohttp/test_aiohttp_middleware.py @@ -8,6 +8,7 @@ import aiohttp from multidict import CIMultiDict +from mastercard_oauth2_client._internal.orchestration import has_nonce_challenge_header from mastercard_oauth2_client.handlers import AsyncOAuth2Handler from mastercard_oauth2_client.integrations.aiohttp import OAuth2Middleware, _AiohttpAdapter from mastercard_oauth2_client.models import HttpRequest @@ -168,6 +169,25 @@ async def resource_handler(resource_request: aiohttp.ClientRequest) -> aiohttp.C asyncio.run(exercise()) +def test_aiohttp_adapter_preserves_repeated_www_authenticate_headers() -> None: + headers = CIMultiDict( + [ + ("WWW-Authenticate", 'Basic realm="example"'), + ("WWW-Authenticate", 'DPoP error="use_dpop_nonce"'), + ] + ) + response = FakeResponse(status=401, headers=headers) + + async def resource_handler(resource_request: aiohttp.ClientRequest) -> aiohttp.ClientResponse: + raise AssertionError(f"Unexpected resource request: {resource_request!r}") + + adapter = _AiohttpAdapter(resource_handler) + response_headers = adapter.response_headers(cast(aiohttp.ClientResponse, response)) + + assert response_headers is headers + assert has_nonce_challenge_header(401, response_headers) + + def test_aiohttp_middleware_buffers_payload_before_delegating() -> None: async def exercise() -> None: response = FakeResponse() diff --git a/tests/unit/openapi/urllib3/test_openapi_urllib3_adapter.py b/tests/unit/openapi/urllib3/test_openapi_urllib3_adapter.py index d2674e9..e88d1a5 100644 --- a/tests/unit/openapi/urllib3/test_openapi_urllib3_adapter.py +++ b/tests/unit/openapi/urllib3/test_openapi_urllib3_adapter.py @@ -8,6 +8,7 @@ import pytest from cryptography.hazmat.primitives.asymmetric import ec, rsa +from urllib3._collections import HTTPHeaderDict from mastercard_oauth2_client import KeyPair, OAuth2Config, OAuth2Error, StaticDPoPKeyProvider, StaticScopeResolver from mastercard_oauth2_client.integrations.openapi import add_oauth2_layer @@ -196,6 +197,28 @@ def test_generated_client_retries_token_and_resource_nonce_challenges_once() -> assert not final_response.read_called and not final_response.drain_called and not final_response.release_called +def test_generated_client_retries_when_dpop_is_not_last_www_authenticate_value() -> None: + challenge_headers = HTTPHeaderDict() + challenge_headers.add("DPoP-Nonce", "resource-nonce") + challenge_headers.add("WWW-Authenticate", 'DPoP error="use_dpop_nonce"') + challenge_headers.add("WWW-Authenticate", 'Basic realm="example"') + rest_client = ScriptedRestClient( + [ + _token_response(), + GeneratedResponse(status=401, headers=challenge_headers, body=b"{}"), + GeneratedResponse(status=200, headers={}, body=b'{"result":"ok"}'), + ] + ) + api_client = GeneratedApiClient(rest_client) + add_oauth2_layer(api_client, _config()) + + response = api_client.rest_client.request("GET", "https://api.example.com/payments", headers={}) + + assert response.status == 200 + assert len(rest_client.requests) == 3 + assert _claims(rest_client.requests[2][2]["headers"]["DPoP"])["nonce"] == "resource-nonce" + + def test_generated_client_preserves_json_post_body() -> None: rest_client = ScriptedRestClient( [ diff --git a/tests/unit/test_orchestration.py b/tests/unit/test_orchestration.py index 58b7fec..441b166 100644 --- a/tests/unit/test_orchestration.py +++ b/tests/unit/test_orchestration.py @@ -1,6 +1,7 @@ import pytest +from multidict import CIMultiDict -from mastercard_oauth2_client._internal.orchestration import has_nonce_challenge_body +from mastercard_oauth2_client._internal.orchestration import has_nonce_challenge_body, has_nonce_challenge_header @pytest.mark.parametrize("body", [None, b"", "not-json", "[]"]) @@ -10,3 +11,14 @@ def test_nonce_challenge_body_ignores_absent_or_invalid_error_objects(body: byte def test_nonce_challenge_body_requires_supported_status() -> None: assert not has_nonce_challenge_body(200, '{"error":"use_dpop_nonce"}') + + +def test_nonce_challenge_header_checks_every_www_authenticate_value() -> None: + headers = CIMultiDict( + [ + ("WWW-Authenticate", 'Basic realm="example"'), + ("WWW-Authenticate", 'DPoP error="use_dpop_nonce"'), + ] + ) + + assert has_nonce_challenge_header(401, headers) From e5d1677c67bad88a094c7a08aff16799d00e20b6 Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Tue, 29 Sep 2026 18:14:19 +0530 Subject: [PATCH 15/36] Review comments: Add test to verify generated HTTPX timeout propagation --- .../httpx/test_openapi_httpx_adapter.py | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/tests/unit/openapi/httpx/test_openapi_httpx_adapter.py b/tests/unit/openapi/httpx/test_openapi_httpx_adapter.py index 3ce25e3..b0bdc2e 100644 --- a/tests/unit/openapi/httpx/test_openapi_httpx_adapter.py +++ b/tests/unit/openapi/httpx/test_openapi_httpx_adapter.py @@ -128,7 +128,23 @@ def create_transport(**kwargs: object) -> httpx.AsyncBaseTransport: assert proxy.headers["Proxy-Authorization"] == "Basic value" -def test_generated_request_timeout_reaches_resource_and_token_transports() -> None: +@pytest.mark.parametrize( + ("request_timeout", "expected_timeout"), + [ + ( + (11.0, 12.0), + {"connect": 11.0, "read": 12.0, "write": None, "pool": None}, + ), + ( + None, + {"connect": 300.0, "read": 300.0, "write": 300.0, "pool": 300.0}, + ), + ], +) +def test_generated_request_timeout_reaches_resource_and_token_transports( + request_timeout: tuple[float, float] | None, + expected_timeout: dict[str, float | None], +) -> None: async def exercise() -> None: requests: list[httpx.Request] = [] @@ -147,17 +163,15 @@ async def dispatch(request: httpx.Request) -> httpx.Response: add_oauth2_layer(api_client, _config(), httpx.MockTransport(dispatch)) try: with pytest.raises(ApiException): - await GeneratedResourcesApi(api_client).get_resource("missing", _request_timeout=(1.0, 2.0)) + await GeneratedResourcesApi(api_client).get_resource( + "missing", _request_timeout=request_timeout + ) finally: await api_client.close() - assert len(requests) == 2 - for request in requests: - assert request.extensions["timeout"] == { - "connect": 1.0, - "read": 2.0, - "write": None, - "pool": None, - } + assert [request.url.host for request in requests] == ["auth.example.com", "api.example.com"] + token_request, resource_request = requests + assert token_request.extensions["timeout"] == expected_timeout + assert resource_request.extensions["timeout"] == expected_timeout asyncio.run(exercise()) From cbbfb6108a75e9e62a86ae12a4a34a6f7c209c08 Mon Sep 17 00:00:00 2001 From: ShimonaR-MC <118438569+ShimonaR-MC@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:16:22 +0530 Subject: [PATCH 16/36] Review comments: Modernize license metadata Co-authored-by: Jean-Alexis Aufauvre <3964455+jaaufauvre@users.noreply.github.com> --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7b25beb..7518b5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,8 @@ version = "1.0.0" description = "Mastercard OAuth 2.0 client authentication for Python" readme = "README.md" requires-python = ">=3.12" -license = { text = "Apache-2.0" } +license = "Apache-2.0" +license-files = ["LICENSE"] dependencies = [ "cryptography>=45.0.1", ] From f5e351082129d33a378b39202dca8ed281a19baf Mon Sep 17 00:00:00 2001 From: ShimonaR-MC <118438569+ShimonaR-MC@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:39:47 +0530 Subject: [PATCH 17/36] Review comments: Update license copyright Co-authored-by: Jean-Alexis Aufauvre <3964455+jaaufauvre@users.noreply.github.com> --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 8674164..8c2051f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2025 - 2026 Mastercard +Copyright 2026 Mastercard Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From 09d81d0f63ef1b169b076c1bf8096ebd99b6ca92 Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Wed, 7 Oct 2026 10:59:01 +0530 Subject: [PATCH 18/36] Review comments: Redact sensitive URL components --- .../_internal/orchestration.py | 2 +- tests/unit/test_handler.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/mastercard_oauth2_client/_internal/orchestration.py b/src/mastercard_oauth2_client/_internal/orchestration.py index d831905..7079292 100644 --- a/src/mastercard_oauth2_client/_internal/orchestration.py +++ b/src/mastercard_oauth2_client/_internal/orchestration.py @@ -42,7 +42,7 @@ def update(self, headers: Mapping[str, str]) -> bool: def require_resource_url(url: str) -> None: if not is_absolute_https_url(url): - raise OAuth2Error(f"FAPI 2.0 requires HTTPS for resource server: {url}") + raise OAuth2Error(f"FAPI 2.0 requires HTTPS for resource server: {safe_url(url)}") def token_filter(scopes: frozenset[str], dpop_key: DPoPKey) -> AccessTokenFilter: diff --git a/tests/unit/test_handler.py b/tests/unit/test_handler.py index 8d69468..f6736e4 100644 --- a/tests/unit/test_handler.py +++ b/tests/unit/test_handler.py @@ -349,6 +349,22 @@ def test_handler_requires_absolute_https_resource_url(resource_url: str) -> None assert transport.requests == [] +def test_handler_omits_query_and_fragment_from_invalid_resource_url_error() -> None: + config = _config() + transport = ScriptedAdapter([]) + request = NativeRequest( + method="GET", + url="http://api.example.com/payments?account=secret#details", + headers={}, + ) + + with pytest.raises(OAuth2Error) as error: + OAuth2Handler[NativeRequest, NativeResponse](config).execute(request, transport) + + assert str(error.value) == "FAPI 2.0 requires HTTPS for resource server: http://api.example.com/payments" + assert transport.requests == [] + + def test_handler_logs_lifecycle_without_sensitive_values(caplog: pytest.LogCaptureFixture) -> None: config = _config() transport = ScriptedAdapter( From f28f1eb98e470aeef1d78865a197f840e31ac272 Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Wed, 7 Oct 2026 11:36:25 +0530 Subject: [PATCH 19/36] Review comments: Reject non-ASCII access tokens --- src/mastercard_oauth2_client/dpop.py | 6 +++++- src/mastercard_oauth2_client/token.py | 2 ++ tests/unit/test_jose_dpop.py | 2 +- tests/unit/test_token.py | 9 +++++++++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/mastercard_oauth2_client/dpop.py b/src/mastercard_oauth2_client/dpop.py index 35e3324..b12ce59 100644 --- a/src/mastercard_oauth2_client/dpop.py +++ b/src/mastercard_oauth2_client/dpop.py @@ -39,7 +39,11 @@ def _canonicalize_htu(url: str) -> str: def _access_token_hash(access_token: str) -> str: """Compute the RFC 9449 section 4.2 ath claim value.""" - digest = hashlib.sha256(access_token.encode("ascii")).digest() + try: + access_token_bytes = access_token.encode("ascii") + except UnicodeEncodeError as error: + raise OAuth2Error("Access token must contain only ASCII characters") from error + digest = hashlib.sha256(access_token_bytes).digest() return _base64url_encode(digest) diff --git a/src/mastercard_oauth2_client/token.py b/src/mastercard_oauth2_client/token.py index b74d3f6..6b62a73 100644 --- a/src/mastercard_oauth2_client/token.py +++ b/src/mastercard_oauth2_client/token.py @@ -167,6 +167,8 @@ def _parse_token_response( value = _read_token_response_json(response.body) access_token = _read_required_string(value, "access_token") + if not access_token.isascii(): + raise OAuth2Error("Access token must contain only ASCII characters") token_type = _read_required_string(value, "token_type") if token_type.casefold() != "dpop": raise OAuth2Error(f"Expected DPoP token type but received: {token_type}") diff --git a/tests/unit/test_jose_dpop.py b/tests/unit/test_jose_dpop.py index 10871c8..1df4c45 100644 --- a/tests/unit/test_jose_dpop.py +++ b/tests/unit/test_jose_dpop.py @@ -240,7 +240,7 @@ def test_resource_dpop_proof_rejects_non_ascii_access_token() -> None: config = _config(rsa.generate_private_key(public_exponent=65537, key_size=2048)) dpop_key = config.dpop_key_provider.get_current_key() - with pytest.raises(UnicodeEncodeError): + with pytest.raises(OAuth2Error, match="Access token must contain only ASCII characters"): create_resource_dpop_proof( config, dpop_key.key_id, diff --git a/tests/unit/test_token.py b/tests/unit/test_token.py index ac8a462..ccc088b 100644 --- a/tests/unit/test_token.py +++ b/tests/unit/test_token.py @@ -171,6 +171,15 @@ def test_parse_token_response_requires_dpop_token_fields(payload: object, messag parse_token_response(config, _response(payload), set(), dpop_key_id) +def test_parse_token_response_rejects_non_ascii_access_token() -> None: + config = _config() + dpop_key_id = config.dpop_key_provider.get_current_key().key_id + payload = {"access_token": "non-ascii-token-ö", "token_type": "DPoP", "expires_in": 900} + + with pytest.raises(OAuth2Error, match="Access token must contain only ASCII characters"): + parse_token_response(config, _response(payload), set(), dpop_key_id) + + @pytest.mark.parametrize("expires_in", [0, -1, True, "900", None]) def test_parse_token_response_requires_positive_numeric_expiry(expires_in: object) -> None: config = _config() From f22b11b4c18d532cc1bd104a441ac11f476ba0ae Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Wed, 7 Oct 2026 11:53:35 +0530 Subject: [PATCH 20/36] Review comments: Validate scope input --- src/mastercard_oauth2_client/defaults.py | 2 ++ tests/unit/test_defaults.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/mastercard_oauth2_client/defaults.py b/src/mastercard_oauth2_client/defaults.py index da01c3d..15a24a2 100644 --- a/src/mastercard_oauth2_client/defaults.py +++ b/src/mastercard_oauth2_client/defaults.py @@ -11,6 +11,8 @@ class StaticScopeResolver: def __init__(self, scopes: AbstractSet[str]) -> None: """Create a resolver for the supplied scopes.""" + if isinstance(scopes, str): + raise TypeError("scopes must be a set of strings, not a string") self._scopes = normalize_scopes(scopes) def resolve(self, method: str, url: str) -> frozenset[str]: diff --git a/tests/unit/test_defaults.py b/tests/unit/test_defaults.py index a26a4f4..8f45406 100644 --- a/tests/unit/test_defaults.py +++ b/tests/unit/test_defaults.py @@ -1,3 +1,4 @@ +import pytest from cryptography.hazmat.primitives.asymmetric import ec from mastercard_oauth2_client import DPoPKeyProvider, KeyPair, ScopeResolver @@ -19,6 +20,17 @@ def test_static_scope_resolver_accepts_any_set_like_collection() -> None: assert resolver.all_scopes() == frozenset({"payments:read"}) +def test_static_scope_resolver_accepts_single_scope_set() -> None: + resolver = StaticScopeResolver({"payments:read"}) + + assert resolver.all_scopes() == frozenset({"payments:read"}) + + +def test_static_scope_resolver_rejects_string() -> None: + with pytest.raises(TypeError, match="scopes must be a set of strings, not a string"): + StaticScopeResolver("payments:read payments:write") # type: ignore[arg-type] + + def test_static_dpop_key_provider_returns_current_and_named_key() -> None: private_key = ec.generate_private_key(ec.SECP256R1()) key_pair = KeyPair(private_key=private_key, public_key=private_key.public_key()) From 89b4345d3dd8b273fe88795d9518f39fb091ed40 Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Wed, 7 Oct 2026 12:48:04 +0530 Subject: [PATCH 21/36] Review comments: Centralize scope validation --- .../_internal/_scopes.py | 2 ++ src/mastercard_oauth2_client/defaults.py | 2 -- tests/unit/test_handler.py | 20 +++++++++++++++++- tests/unit/test_token.py | 21 +++++++++++++++++++ 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/mastercard_oauth2_client/_internal/_scopes.py b/src/mastercard_oauth2_client/_internal/_scopes.py index 8ea8358..a34b91c 100644 --- a/src/mastercard_oauth2_client/_internal/_scopes.py +++ b/src/mastercard_oauth2_client/_internal/_scopes.py @@ -4,4 +4,6 @@ def normalize_scopes(scopes: AbstractSet[str]) -> frozenset[str]: """Return non-empty scopes with surrounding whitespace removed.""" + if isinstance(scopes, str): + raise TypeError("scopes must be a set of strings, not a string") return frozenset(scope.strip() for scope in scopes if scope.strip()) diff --git a/src/mastercard_oauth2_client/defaults.py b/src/mastercard_oauth2_client/defaults.py index 15a24a2..da01c3d 100644 --- a/src/mastercard_oauth2_client/defaults.py +++ b/src/mastercard_oauth2_client/defaults.py @@ -11,8 +11,6 @@ class StaticScopeResolver: def __init__(self, scopes: AbstractSet[str]) -> None: """Create a resolver for the supplied scopes.""" - if isinstance(scopes, str): - raise TypeError("scopes must be a set of strings, not a string") self._scopes = normalize_scopes(scopes) def resolve(self, method: str, url: str) -> frozenset[str]: diff --git a/tests/unit/test_handler.py b/tests/unit/test_handler.py index f6736e4..0e27504 100644 --- a/tests/unit/test_handler.py +++ b/tests/unit/test_handler.py @@ -2,7 +2,7 @@ import json from collections.abc import Iterable, Mapping from collections.abc import Set as AbstractSet -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import UTC, datetime from threading import Lock from urllib.parse import parse_qs @@ -101,6 +101,14 @@ def send_token_request(self, resource_request: NativeRequest, request: HttpReque raise ConnectionError("network unavailable") +class StringReturningScopeResolver: + def resolve(self, method: str, url: str) -> frozenset[str]: + return "payments:read" # type: ignore[return-value] + + def all_scopes(self) -> frozenset[str]: + return frozenset({"payments:read"}) + + def _config(token_store: InMemoryTokenStore | None = None) -> OAuth2Config: client_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) dpop_private_key = ec.generate_private_key(ec.SECP256R1()) @@ -337,6 +345,16 @@ def test_handler_preserves_transport_errors() -> None: OAuth2Handler[NativeRequest, NativeResponse](config).execute(_resource_request(), FailingAdapter()) +def test_handler_rejects_string_scopes_from_custom_resolver() -> None: + config = replace(_config(), scope_resolver=StringReturningScopeResolver()) + transport = ScriptedAdapter([]) + + with pytest.raises(TypeError, match="scopes must be a set of strings, not a string"): + OAuth2Handler[NativeRequest, NativeResponse](config).execute(_resource_request(), transport) + + assert transport.requests == [] + + @pytest.mark.parametrize("resource_url", ["http://api.example.com/payments", "/payments", "not-a-url"]) def test_handler_requires_absolute_https_resource_url(resource_url: str) -> None: config = _config() diff --git a/tests/unit/test_token.py b/tests/unit/test_token.py index ccc088b..7ba1cfa 100644 --- a/tests/unit/test_token.py +++ b/tests/unit/test_token.py @@ -84,6 +84,14 @@ def test_build_token_request_omits_empty_scope() -> None: assert "scope" not in parse_qs(request.body, keep_blank_values=True) +def test_build_token_request_rejects_string_scopes() -> None: + config = _config() + dpop_key_id = config.dpop_key_provider.get_current_key().key_id + + with pytest.raises(TypeError, match="scopes must be a set of strings, not a string"): + build_token_request(config, "payments:read", dpop_key_id) # type: ignore[arg-type] + + def test_parse_token_response_returns_cache_ready_dpop_token(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(token_module, "_utc_now", lambda: _FIXED_NOW) config = _config() @@ -142,6 +150,19 @@ def test_parse_token_response_uses_requested_scopes_when_scope_is_omitted( assert token.scopes == frozenset({"payments:read"}) +def test_parse_token_response_rejects_string_requested_scopes() -> None: + config = _config() + dpop_key_id = config.dpop_key_provider.get_current_key().key_id + + with pytest.raises(TypeError, match="scopes must be a set of strings, not a string"): + parse_token_response( + config, + _response({"access_token": "access-token", "token_type": "DPoP", "expires_in": 900}), + "payments:read", + dpop_key_id, + ) # type: ignore[arg-type] + + @pytest.mark.parametrize( ("payload", "message"), [ From 679d1c2ba219eb89405555761697dee05c4c515c Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Wed, 7 Oct 2026 13:01:55 +0530 Subject: [PATCH 22/36] Fix flaky EC thumbprint test --- tests/unit/test_jose_dpop.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/unit/test_jose_dpop.py b/tests/unit/test_jose_dpop.py index 1df4c45..4484c1c 100644 --- a/tests/unit/test_jose_dpop.py +++ b/tests/unit/test_jose_dpop.py @@ -35,9 +35,9 @@ def fixed_proof_values(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(dpop_module, "_random_jti", lambda: _FIXED_JTI) -def _base64url_uint(value: int) -> str: - size = max(1, (value.bit_length() + 7) // 8) - return base64.urlsafe_b64encode(value.to_bytes(size, "big")).rstrip(b"=").decode("ascii") +def _base64url_uint(value: int, size: int | None = None) -> str: + length = size if size is not None else max(1, (value.bit_length() + 7) // 8) + return base64.urlsafe_b64encode(value.to_bytes(length, "big")).rstrip(b"=").decode("ascii") def _private_jwk(private_key: rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey) -> dict[str, str]: @@ -58,12 +58,13 @@ def _private_jwk(private_key: rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey) -> ec_numbers = private_key.private_numbers() ec_public = ec_numbers.public_numbers + component_size = (private_key.curve.key_size + 7) // 8 return { "kty": "EC", "crv": "P-256", - "x": _base64url_uint(ec_public.x), - "y": _base64url_uint(ec_public.y), - "d": _base64url_uint(ec_numbers.private_value), + "x": _base64url_uint(ec_public.x, component_size), + "y": _base64url_uint(ec_public.y, component_size), + "d": _base64url_uint(ec_numbers.private_value, component_size), } @@ -251,7 +252,7 @@ def test_resource_dpop_proof_rejects_non_ascii_access_token() -> None: def test_static_dpop_provider_accepts_key_pair_and_uses_thumbprint_id() -> None: - private_key = ec.generate_private_key(ec.SECP256R1()) + private_key = ec.derive_private_key(43, ec.SECP256R1()) provider = StaticDPoPKeyProvider(KeyPair(private_key=private_key, public_key=private_key.public_key())) current_key = provider.get_current_key() @@ -261,8 +262,8 @@ def test_static_dpop_provider_accepts_key_pair_and_uses_thumbprint_id() -> None: { "crv": "P-256", "kty": "EC", - "x": _base64url_uint(public_numbers.x), - "y": _base64url_uint(public_numbers.y), + "x": _base64url_uint(public_numbers.x, 32), + "y": _base64url_uint(public_numbers.y, 32), }, separators=(",", ":"), sort_keys=True, From dfa77dfbcb36a73513a7c348096ed8deb5094f5f Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Wed, 7 Oct 2026 14:06:00 +0530 Subject: [PATCH 23/36] Review comments: Detect unclosed resources --- pyproject.toml | 4 ++++ .../direct/aiohttp/test_aiohttp_client.py | 19 +++++++++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7518b5d..696e975 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,10 @@ packages = ["src/mastercard_oauth2_client"] testpaths = ["tests/unit", "tests/integration"] addopts = "--strict-config" pythonpath = ["src", "tests/generated/openapi", "tests/generated/openapi_httpx", "tests/generated/openapi_asyncio"] +filterwarnings = [ + "error::ResourceWarning", + "error::pytest.PytestUnraisableExceptionWarning", +] [tool.ruff] target-version = "py312" diff --git a/tests/integration/direct/aiohttp/test_aiohttp_client.py b/tests/integration/direct/aiohttp/test_aiohttp_client.py index 911085f..b6d7386 100644 --- a/tests/integration/direct/aiohttp/test_aiohttp_client.py +++ b/tests/integration/direct/aiohttp/test_aiohttp_client.py @@ -88,17 +88,19 @@ async def body_stream() -> AsyncIterator[bytes]: yield body[:10] yield body[10:] - async def exercise() -> aiohttp.ClientResponse: + async def exercise() -> int: async with _client(oauth_environment) as client: - return await client.post( + response = await client.post( f"{oauth_environment.resource.base_url}/api/resources", headers={"Content-Type": "application/json"}, data=body_stream(), ) + await response.read() + return response.status - response = asyncio.run(exercise()) + status = asyncio.run(exercise()) - assert response.status == 201 + assert status == 201 assert [request.body for request in oauth_environment.resource.state.requests] == [body, body] @@ -187,6 +189,7 @@ async def exercise() -> tuple[int, int]: headers = {"Accept": "application/json"} first = await client.get(f"{oauth_environment.resource.base_url}/api/resources/missing", headers=headers) second = await client.get(f"{oauth_environment.resource.base_url}/api/resources/missing", headers=headers) + await asyncio.gather(first.read(), second.read()) return first.status, second.status assert asyncio.run(exercise()) == (404, 404) @@ -197,9 +200,11 @@ def test_aiohttp_client_reacquires_invalidated_token(oauth_environment: OAuthTes async def exercise(config: OAuth2Config, store: InvalidatableTokenStore) -> None: async with _client(oauth_environment, config=config) as client: headers = {"Accept": "application/json"} - await client.get(f"{oauth_environment.resource.base_url}/api/resources/missing", headers=headers) + first = await client.get(f"{oauth_environment.resource.base_url}/api/resources/missing", headers=headers) + await first.read() store.invalidate() - await client.get(f"{oauth_environment.resource.base_url}/api/resources/missing", headers=headers) + second = await client.get(f"{oauth_environment.resource.base_url}/api/resources/missing", headers=headers) + await second.read() store = InvalidatableTokenStore() asyncio.run(exercise(oauth_environment.config("ec", token_store=store), store)) @@ -242,6 +247,7 @@ async def exercise() -> int: f"{oauth_environment.resource.base_url}/api/resources/missing", headers={"Accept": "application/json"}, ) + await response.read() return response.status oauth_environment.authorization.state.always_challenge_nonce = True @@ -301,6 +307,7 @@ async def exercise() -> int: f"{oauth_environment.resource.base_url}/api/resources/missing", headers={"Accept": "application/json"}, ) + await response.read() return response.status oauth_environment.resource.state.always_challenge_nonce = True From d7fb8c85ba72a1213465f81ba3639f9febe6e57a Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Wed, 7 Oct 2026 14:40:49 +0530 Subject: [PATCH 24/36] Review comments: Fix README links for PyPI --- README.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 1301a97..7bf00a2 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ For more information, see ### License -[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/Mastercard/oauth2-client-python/blob/main/LICENSE) This project is licensed under the Apache License 2.0. @@ -105,10 +105,10 @@ and token storage, see [Extension Points](#extension-points). ### Low-Level API -[`OAuth2Handler`](src/mastercard_oauth2_client/handlers/sync.py) and -[`AsyncOAuth2Handler`](src/mastercard_oauth2_client/handlers/async_.py) run the +[`OAuth2Handler`](https://github.com/Mastercard/oauth2-client-python/blob/main/src/mastercard_oauth2_client/handlers/sync.py) and +[`AsyncOAuth2Handler`](https://github.com/Mastercard/oauth2-client-python/blob/main/src/mastercard_oauth2_client/handlers/async_.py) run the OAuth2 and DPoP flow. To support another HTTP client, provide the matching -[`SyncHttpAdapter` or `AsyncHttpAdapter`](src/mastercard_oauth2_client/protocols.py) +[`SyncHttpAdapter` or `AsyncHttpAdapter`](https://github.com/Mastercard/oauth2-client-python/blob/main/src/mastercard_oauth2_client/protocols.py) and pass it to the handler. For higher-level use, choose a ready-made integration below. @@ -268,20 +268,20 @@ finally: This library is small by design and can be extended for different application needs. -- [`OAuth2Config`](src/mastercard_oauth2_client/config.py) connects credentials, +- [`OAuth2Config`](https://github.com/Mastercard/oauth2-client-python/blob/main/src/mastercard_oauth2_client/config.py) connects credentials, endpoints and extension points. -- Use [`StaticScopeResolver`](src/mastercard_oauth2_client/defaults.py) for +- Use [`StaticScopeResolver`](https://github.com/Mastercard/oauth2-client-python/blob/main/src/mastercard_oauth2_client/defaults.py) for fixed scopes, or provide a custom - [`ScopeResolver`](src/mastercard_oauth2_client/protocols.py) to choose scopes + [`ScopeResolver`](https://github.com/Mastercard/oauth2-client-python/blob/main/src/mastercard_oauth2_client/protocols.py) to choose scopes for each request. -- Use [`StaticDPoPKeyProvider`](src/mastercard_oauth2_client/defaults.py) for +- Use [`StaticDPoPKeyProvider`](https://github.com/Mastercard/oauth2-client-python/blob/main/src/mastercard_oauth2_client/defaults.py) for one key pair, or provide a custom - [`DPoPKeyProvider`](src/mastercard_oauth2_client/protocols.py) to supply or + [`DPoPKeyProvider`](https://github.com/Mastercard/oauth2-client-python/blob/main/src/mastercard_oauth2_client/protocols.py) to supply or rotate keys. - Use the default `InMemoryTokenStore`, or provide a custom - [`TokenStore`](src/mastercard_oauth2_client/protocols.py) to control token + [`TokenStore`](https://github.com/Mastercard/oauth2-client-python/blob/main/src/mastercard_oauth2_client/protocols.py) to control token storage. -- [Key utilities](src/mastercard_oauth2_client/keys.py): generate supported key +- [Key utilities](https://github.com/Mastercard/oauth2-client-python/blob/main/src/mastercard_oauth2_client/keys.py): generate supported key pairs and load PEM, DER, PKCS#12 and private JWK key material. ## Key Support @@ -316,18 +316,18 @@ and response bodies are not logged. ## Test Strategy -- [`tests/unit`](tests/unit) contains fast protocol, handler and adapter tests, +- [`tests/unit`](https://github.com/Mastercard/oauth2-client-python/tree/main/tests/unit) contains fast protocol, handler and adapter tests, with client-specific tests grouped under `direct` and `openapi`. -- [`tests/integration`](tests/integration) tests direct and generated clients +- [`tests/integration`](https://github.com/Mastercard/oauth2-client-python/tree/main/tests/integration) tests direct and generated clients against local HTTPS servers, grouped under `direct` and `openapi`. -- [`tests/live`](tests/live) calls the real Mastercard sandbox. These optional +- [`tests/live`](https://github.com/Mastercard/oauth2-client-python/tree/main/tests/live) calls the real Mastercard sandbox. These optional tests skip when sandbox credentials are not configured. The OpenAPI specifications have separate purposes: -- [`fake-api.yaml`](tests/resources/openapi/fake-api.yaml) generates clients for +- [`fake-api.yaml`](https://github.com/Mastercard/oauth2-client-python/blob/main/tests/resources/openapi/fake-api.yaml) generates clients for local integration tests. -- [`petstore-api.yaml`](tests/resources/openapi/petstore-api.yaml) generates +- [`petstore-api.yaml`](https://github.com/Mastercard/oauth2-client-python/blob/main/tests/resources/openapi/petstore-api.yaml) generates clients for real Mastercard sandbox tests. To run all tests that do not require generated OpenAPI clients: @@ -361,7 +361,7 @@ python -m pytest -v tests/unit/openapi/httpx tests/integration/openapi/httpx ``` To run sandbox tests locally, set the variables shown in -[`.env.example`](.env.example). You may export them in your shell or copy that +[`.env.example`](https://github.com/Mastercard/oauth2-client-python/blob/main/.env.example). You may export them in your shell or copy that file to `.env` as a convenience. Set `LIVE_HTTP_CLIENT` to `requests`, `httpx`, `httpx-async`, `aiohttp`, `openapi-urllib3`, `openapi-httpx` or `openapi-asyncio`, and set `LIVE_DPOP_ALGORITHM` to `ec` or `rsa`. @@ -402,7 +402,7 @@ python -m build ``` Tested Python, HTTP client and generator combinations are defined in the -[CI workflows](.github/workflows). +[CI workflows](https://github.com/Mastercard/oauth2-client-python/tree/main/.github/workflows). ### Code Style From 6defece85d2415d25437d5f401abfff730e198af Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Wed, 7 Oct 2026 19:00:54 +0530 Subject: [PATCH 25/36] Review comments: Add PyPI package metadata --- pyproject.toml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 696e975..792f545 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,9 +7,23 @@ name = "mastercard-oauth2-client" version = "1.0.0" description = "Mastercard OAuth 2.0 client authentication for Python" readme = "README.md" +authors = [{ name = "Mastercard" }] +keywords = ["oauth2", "fapi2", "dpop", "jwt", "authentication", "security", "mastercard"] requires-python = ">=3.12" license = "Apache-2.0" license-files = ["LICENSE"] +classifiers = [ + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] dependencies = [ "cryptography>=45.0.1", ] From 52046729b13b1127c06ed45f1b0db032c11f9dbc Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Wed, 7 Oct 2026 23:12:03 +0530 Subject: [PATCH 26/36] Review comments: Add Python 3.11 support --- .github/workflows/ci-aiohttp.yml | 2 +- .github/workflows/ci-httpx.yml | 2 +- .github/workflows/ci-openapi-asyncio.yml | 2 +- .github/workflows/ci-openapi-httpx.yml | 2 +- .github/workflows/ci-python.yml | 2 ++ .github/workflows/ci-requests.yml | 2 +- .github/workflows/ci-urllib3.yml | 2 +- .github/workflows/quality.yml | 2 +- README.md | 4 ++-- pyproject.toml | 7 ++++--- src/mastercard_oauth2_client/_internal/_clock.py | 3 ++- src/mastercard_oauth2_client/_internal/jose.py | 4 ++-- src/mastercard_oauth2_client/dpop.py | 3 ++- src/mastercard_oauth2_client/handlers/async_.py | 6 +++++- src/mastercard_oauth2_client/handlers/sync.py | 6 +++++- .../integrations/openapi/urllib3.py | 4 ++-- src/mastercard_oauth2_client/keys.py | 5 +++-- src/mastercard_oauth2_client/models.py | 5 +++-- src/mastercard_oauth2_client/protocols.py | 9 ++++++--- src/mastercard_oauth2_client/store.py | 3 ++- tests/integration/support/models.py | 4 ++-- 21 files changed, 49 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci-aiohttp.yml b/.github/workflows/ci-aiohttp.yml index 25c90dc..2f66a22 100644 --- a/.github/workflows/ci-aiohttp.yml +++ b/.github/workflows/ci-aiohttp.yml @@ -31,7 +31,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.12' + python-version: '3.11' cache: pip - name: Install dependencies diff --git a/.github/workflows/ci-httpx.yml b/.github/workflows/ci-httpx.yml index ac76f41..0ccbba3 100644 --- a/.github/workflows/ci-httpx.yml +++ b/.github/workflows/ci-httpx.yml @@ -31,7 +31,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.12' + python-version: '3.11' cache: pip - name: Install dependencies diff --git a/.github/workflows/ci-openapi-asyncio.yml b/.github/workflows/ci-openapi-asyncio.yml index ec78fde..5b6047c 100644 --- a/.github/workflows/ci-openapi-asyncio.yml +++ b/.github/workflows/ci-openapi-asyncio.yml @@ -31,7 +31,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.12' + python-version: '3.11' cache: pip - name: Install dependencies diff --git a/.github/workflows/ci-openapi-httpx.yml b/.github/workflows/ci-openapi-httpx.yml index faab798..197ba8b 100644 --- a/.github/workflows/ci-openapi-httpx.yml +++ b/.github/workflows/ci-openapi-httpx.yml @@ -31,7 +31,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.12' + python-version: '3.11' cache: pip - name: Install dependencies diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml index dd0ed64..4eac796 100644 --- a/.github/workflows/ci-python.yml +++ b/.github/workflows/ci-python.yml @@ -20,6 +20,8 @@ jobs: fail-fast: false matrix: include: + - python-version: '3.11' + cryptography: 'cryptography==45.0.1' - python-version: '3.12' cryptography: 'cryptography==45.0.1' - python-version: '3.13' diff --git a/.github/workflows/ci-requests.yml b/.github/workflows/ci-requests.yml index 01fabd7..60924b2 100644 --- a/.github/workflows/ci-requests.yml +++ b/.github/workflows/ci-requests.yml @@ -31,7 +31,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.12' + python-version: '3.11' cache: pip - name: Install dependencies diff --git a/.github/workflows/ci-urllib3.yml b/.github/workflows/ci-urllib3.yml index ce444de..2199ef0 100644 --- a/.github/workflows/ci-urllib3.yml +++ b/.github/workflows/ci-urllib3.yml @@ -31,7 +31,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.12' + python-version: '3.11' cache: pip - name: Install dependencies diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 1cefb02..531159b 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -22,7 +22,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.12' + python-version: '3.11' cache: pip - name: Install dependencies diff --git a/README.md b/README.md index 7bf00a2..afe0264 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,9 @@ This project is licensed under the Apache License 2.0. | Versions | Status | |---|---| -| 3.12, 3.13, 3.14 | [![Python](https://github.com/Mastercard/oauth2-client-python/actions/workflows/ci-python.yml/badge.svg)](https://github.com/Mastercard/oauth2-client-python/actions/workflows/ci-python.yml) | +| 3.11, 3.12, 3.13, 3.14 | [![Python](https://github.com/Mastercard/oauth2-client-python/actions/workflows/ci-python.yml/badge.svg)](https://github.com/Mastercard/oauth2-client-python/actions/workflows/ci-python.yml) | -Python 3.12 or newer is required. +Python 3.11 or newer is required. ### Dependencies diff --git a/pyproject.toml b/pyproject.toml index 792f545..de20749 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ description = "Mastercard OAuth 2.0 client authentication for Python" readme = "README.md" authors = [{ name = "Mastercard" }] keywords = ["oauth2", "fapi2", "dpop", "jwt", "authentication", "security", "mastercard"] -requires-python = ">=3.12" +requires-python = ">=3.11" license = "Apache-2.0" license-files = ["LICENSE"] classifiers = [ @@ -18,6 +18,7 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", @@ -73,12 +74,12 @@ filterwarnings = [ ] [tool.ruff] -target-version = "py312" +target-version = "py311" line-length = 120 extend-exclude = ["tests/generated"] [tool.mypy] -python_version = "3.12" +python_version = "3.11" strict = true files = ["src/mastercard_oauth2_client", "scripts"] mypy_path = "src" diff --git a/src/mastercard_oauth2_client/_internal/_clock.py b/src/mastercard_oauth2_client/_internal/_clock.py index 2e400ce..f3822ef 100644 --- a/src/mastercard_oauth2_client/_internal/_clock.py +++ b/src/mastercard_oauth2_client/_internal/_clock.py @@ -1,7 +1,8 @@ from collections.abc import Callable from datetime import UTC, datetime +from typing import TypeAlias -type NowProvider = Callable[[], datetime] +NowProvider: TypeAlias = Callable[[], datetime] def utc_now() -> datetime: diff --git a/src/mastercard_oauth2_client/_internal/jose.py b/src/mastercard_oauth2_client/_internal/jose.py index 7b30145..6536b07 100644 --- a/src/mastercard_oauth2_client/_internal/jose.py +++ b/src/mastercard_oauth2_client/_internal/jose.py @@ -9,7 +9,7 @@ import hashlib import json from collections.abc import Mapping -from typing import Literal +from typing import Literal, TypeAlias from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa, utils @@ -18,7 +18,7 @@ from ..models import KeyPair, PrivateKey, PublicKey from ._encoding import base64url_encode as _base64url_encode -type JwsAlgorithm = Literal["PS256", "ES256"] +JwsAlgorithm: TypeAlias = Literal["PS256", "ES256"] _ALGORITHM_PS256: JwsAlgorithm = "PS256" _ALGORITHM_ES256: JwsAlgorithm = "ES256" diff --git a/src/mastercard_oauth2_client/dpop.py b/src/mastercard_oauth2_client/dpop.py index b12ce59..9a57385 100644 --- a/src/mastercard_oauth2_client/dpop.py +++ b/src/mastercard_oauth2_client/dpop.py @@ -1,6 +1,7 @@ import hashlib import secrets from collections.abc import Callable +from typing import TypeAlias from urllib.parse import urlsplit, urlunsplit from ._internal._clock import NowProvider @@ -11,7 +12,7 @@ from .exceptions import OAuth2Error from .keys import validate_dpop_key -type JtiProvider = Callable[[], str] +JtiProvider: TypeAlias = Callable[[], str] _PROOF_LIFETIME_SECONDS = 90 diff --git a/src/mastercard_oauth2_client/handlers/async_.py b/src/mastercard_oauth2_client/handlers/async_.py index a770e92..b45cf35 100644 --- a/src/mastercard_oauth2_client/handlers/async_.py +++ b/src/mastercard_oauth2_client/handlers/async_.py @@ -1,5 +1,6 @@ import logging from collections.abc import Mapping +from typing import Generic, TypeVar from .._internal._scopes import normalize_scopes from .._internal.http import is_success_status @@ -22,8 +23,11 @@ _LOGGER = logging.getLogger(__name__) +RequestT = TypeVar("RequestT") +ResponseT = TypeVar("ResponseT") -class AsyncOAuth2Handler[RequestT, ResponseT]: + +class AsyncOAuth2Handler(Generic[RequestT, ResponseT]): """Authenticate asynchronous resource requests with OAuth2 and DPoP.""" def __init__(self, config: OAuth2Config) -> None: diff --git a/src/mastercard_oauth2_client/handlers/sync.py b/src/mastercard_oauth2_client/handlers/sync.py index 000348e..9d6d1e9 100644 --- a/src/mastercard_oauth2_client/handlers/sync.py +++ b/src/mastercard_oauth2_client/handlers/sync.py @@ -1,5 +1,6 @@ import logging from collections.abc import Mapping +from typing import Generic, TypeVar from .._internal._scopes import normalize_scopes from .._internal.http import is_success_status @@ -22,8 +23,11 @@ _LOGGER = logging.getLogger(__name__) +RequestT = TypeVar("RequestT") +ResponseT = TypeVar("ResponseT") -class OAuth2Handler[RequestT, ResponseT]: + +class OAuth2Handler(Generic[RequestT, ResponseT]): """Authenticate synchronous resource requests with OAuth2 and DPoP.""" def __init__(self, config: OAuth2Config) -> None: diff --git a/src/mastercard_oauth2_client/integrations/openapi/urllib3.py b/src/mastercard_oauth2_client/integrations/openapi/urllib3.py index 60b88c5..ba75b7d 100644 --- a/src/mastercard_oauth2_client/integrations/openapi/urllib3.py +++ b/src/mastercard_oauth2_client/integrations/openapi/urllib3.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass from functools import wraps -from typing import Any, Protocol +from typing import Any, Protocol, TypeAlias from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from ...config import OAuth2Config @@ -32,7 +32,7 @@ def read(self) -> bytes | str | None: ... def getheaders(self) -> Mapping[str, str]: ... -type GeneratedRequest = Callable[..., _GeneratedResponse] +GeneratedRequest: TypeAlias = Callable[..., _GeneratedResponse] class _RestClient(Protocol): diff --git a/src/mastercard_oauth2_client/keys.py b/src/mastercard_oauth2_client/keys.py index 7afd526..79f636f 100644 --- a/src/mastercard_oauth2_client/keys.py +++ b/src/mastercard_oauth2_client/keys.py @@ -2,6 +2,7 @@ from collections.abc import Mapping from os import PathLike from pathlib import Path +from typing import TypeAlias from cryptography.exceptions import UnsupportedAlgorithm from cryptography.hazmat.primitives import serialization @@ -12,8 +13,8 @@ from .exceptions import OAuth2ConfigError from .models import DPoPKey, KeyPair, PrivateKey -type KeySource = str | PathLike[str] | bytes -type JwkSource = KeySource | Mapping[str, str | list[str]] +KeySource: TypeAlias = str | PathLike[str] | bytes +JwkSource: TypeAlias = KeySource | Mapping[str, str | list[str]] _MINIMUM_RSA_KEY_SIZE = 2048 _RSA_PUBLIC_EXPONENT = 65537 diff --git a/src/mastercard_oauth2_client/models.py b/src/mastercard_oauth2_client/models.py index 25d12c6..53c79ac 100644 --- a/src/mastercard_oauth2_client/models.py +++ b/src/mastercard_oauth2_client/models.py @@ -1,11 +1,12 @@ from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime +from typing import TypeAlias from cryptography.hazmat.primitives.asymmetric import ec, rsa -type PrivateKey = rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey -type PublicKey = rsa.RSAPublicKey | ec.EllipticCurvePublicKey +PrivateKey: TypeAlias = rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey +PublicKey: TypeAlias = rsa.RSAPublicKey | ec.EllipticCurvePublicKey @dataclass(frozen=True, slots=True) diff --git a/src/mastercard_oauth2_client/protocols.py b/src/mastercard_oauth2_client/protocols.py index 734a491..06d1471 100644 --- a/src/mastercard_oauth2_client/protocols.py +++ b/src/mastercard_oauth2_client/protocols.py @@ -1,8 +1,11 @@ from collections.abc import Mapping -from typing import Protocol +from typing import Protocol, TypeVar from .models import AccessToken, AccessTokenFilter, DPoPKey, HttpRequest +RequestT = TypeVar("RequestT", contravariant=True) # noqa: PLC0105 - adapters consume requests +ResponseT = TypeVar("ResponseT") + class ScopeResolver(Protocol): """Determine which OAuth2 scopes to request for API calls.""" @@ -40,7 +43,7 @@ def get_key(self, key_id: str) -> DPoPKey: ... -class SyncHttpAdapter[RequestT, ResponseT](Protocol): +class SyncHttpAdapter(Protocol[RequestT, ResponseT]): """Connect OAuth2 orchestration to a synchronous HTTP client. Do not close or consume a response that will be returned to the caller. The @@ -84,7 +87,7 @@ def close_response(self, response: ResponseT) -> None: ... -class AsyncHttpAdapter[RequestT, ResponseT](Protocol): +class AsyncHttpAdapter(Protocol[RequestT, ResponseT]): """Connect OAuth2 orchestration to an asynchronous HTTP client. Do not close or consume a response that will be returned to the caller. The diff --git a/src/mastercard_oauth2_client/store.py b/src/mastercard_oauth2_client/store.py index 30b3946..dd9a032 100644 --- a/src/mastercard_oauth2_client/store.py +++ b/src/mastercard_oauth2_client/store.py @@ -1,11 +1,12 @@ from datetime import timedelta from threading import RLock +from typing import TypeAlias from ._internal._clock import NowProvider from ._internal._clock import utc_now as _utc_now from .models import AccessToken, AccessTokenFilter -type TokenCacheKey = tuple[str | None, tuple[str, ...]] +TokenCacheKey: TypeAlias = tuple[str | None, tuple[str, ...]] class InMemoryTokenStore: diff --git a/tests/integration/support/models.py b/tests/integration/support/models.py index e63bb5a..3728860 100644 --- a/tests/integration/support/models.py +++ b/tests/integration/support/models.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from threading import Barrier -from typing import Literal +from typing import Literal, TypeAlias from mastercard_oauth2_client.models import PublicKey @@ -70,4 +70,4 @@ def reset(self) -> None: self.redirect_location = None -type DPoPAlgorithm = Literal["ec", "rsa"] +DPoPAlgorithm: TypeAlias = Literal["ec", "rsa"] From 0ba2146eb05ef7f9f53d9a1b275bb589a263ea7d Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Thu, 8 Oct 2026 00:33:53 +0530 Subject: [PATCH 27/36] Review comments: Move development dependencies to PEP 735 groups --- .github/workflows/ci-aiohttp.yml | 2 +- .github/workflows/ci-httpx.yml | 2 +- .github/workflows/ci-openapi-asyncio.yml | 2 +- .github/workflows/ci-openapi-httpx.yml | 2 +- .github/workflows/ci-openapi.yml | 2 +- .github/workflows/ci-python.yml | 2 +- .github/workflows/ci-requests.yml | 2 +- .github/workflows/ci-urllib3.yml | 2 +- .github/workflows/live-test.yml | 5 ++--- .github/workflows/quality.yml | 2 +- README.md | 2 +- pyproject.toml | 20 +++++++++++--------- 12 files changed, 23 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci-aiohttp.yml b/.github/workflows/ci-aiohttp.yml index 2f66a22..427dbe8 100644 --- a/.github/workflows/ci-aiohttp.yml +++ b/.github/workflows/ci-aiohttp.yml @@ -37,7 +37,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install -e ".[dev]" "${{ matrix.aiohttp }}" + python -m pip install -e . --group dev "${{ matrix.aiohttp }}" python -m pip check - name: Run direct aiohttp unit tests diff --git a/.github/workflows/ci-httpx.yml b/.github/workflows/ci-httpx.yml index 0ccbba3..d325f60 100644 --- a/.github/workflows/ci-httpx.yml +++ b/.github/workflows/ci-httpx.yml @@ -37,7 +37,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install -e ".[dev]" "${{ matrix.httpx }}" + python -m pip install -e . --group dev "${{ matrix.httpx }}" python -m pip check - name: Run direct HTTPX unit tests diff --git a/.github/workflows/ci-openapi-asyncio.yml b/.github/workflows/ci-openapi-asyncio.yml index 5b6047c..80fccb6 100644 --- a/.github/workflows/ci-openapi-asyncio.yml +++ b/.github/workflows/ci-openapi-asyncio.yml @@ -37,7 +37,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install -e ".[dev,generator]" "${{ matrix.aiohttp }}" + python -m pip install -e . --group dev --group generator "${{ matrix.aiohttp }}" python -m pip check - name: Generate OpenAPI asyncio test client diff --git a/.github/workflows/ci-openapi-httpx.yml b/.github/workflows/ci-openapi-httpx.yml index 197ba8b..17777f5 100644 --- a/.github/workflows/ci-openapi-httpx.yml +++ b/.github/workflows/ci-openapi-httpx.yml @@ -37,7 +37,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install -e ".[dev,generator]" "${{ matrix.httpx }}" + python -m pip install -e . --group dev --group generator "${{ matrix.httpx }}" python -m pip check - name: Generate OpenAPI HTTPX test client diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml index 78d5e3d..0b81694 100644 --- a/.github/workflows/ci-openapi.yml +++ b/.github/workflows/ci-openapi.yml @@ -35,7 +35,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install -e ".[dev]" "openapi-generator-cli[jdk4py]==${{ matrix['generator-version'] }}" + python -m pip install -e . --group dev "openapi-generator-cli[jdk4py]==${{ matrix['generator-version'] }}" python -m pip check - name: Generate all OpenAPI test clients diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml index 4eac796..b49f019 100644 --- a/.github/workflows/ci-python.yml +++ b/.github/workflows/ci-python.yml @@ -41,7 +41,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install -e ".[dev]" "${{ matrix.cryptography }}" + python -m pip install -e . --group dev "${{ matrix.cryptography }}" python -m pip check - name: Run unit tests diff --git a/.github/workflows/ci-requests.yml b/.github/workflows/ci-requests.yml index 60924b2..d3bfe7e 100644 --- a/.github/workflows/ci-requests.yml +++ b/.github/workflows/ci-requests.yml @@ -37,7 +37,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install -e ".[dev]" "${{ matrix.requests }}" + python -m pip install -e . --group dev "${{ matrix.requests }}" python -m pip check - name: Run Requests unit tests diff --git a/.github/workflows/ci-urllib3.yml b/.github/workflows/ci-urllib3.yml index 2199ef0..def22ed 100644 --- a/.github/workflows/ci-urllib3.yml +++ b/.github/workflows/ci-urllib3.yml @@ -37,7 +37,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install -e ".[dev,generator]" "${{ matrix.urllib3 }}" + python -m pip install -e . --group dev --group generator "${{ matrix.urllib3 }}" python -m pip check - name: Generate OpenAPI urllib3 test client diff --git a/.github/workflows/live-test.yml b/.github/workflows/live-test.yml index f1bb70e..7f1ca5f 100644 --- a/.github/workflows/live-test.yml +++ b/.github/workflows/live-test.yml @@ -96,14 +96,13 @@ jobs: python -m pip install --upgrade pip wheel=$(find dist -name '*.whl' -print -quit) test -n "$wheel" - python -m pip install "${wheel}[dev]" + python -m pip install "$wheel" --group dev python -m pip check - name: Install OpenAPI Generator if: startsWith(matrix.client, 'openapi-') run: | - wheel=$(find dist -name '*.whl' -print -quit) - python -m pip install "${wheel}[generator]" + python -m pip install --group generator - name: Generate live OpenAPI clients if: startsWith(matrix.client, 'openapi-') diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 531159b..fbe8c54 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -28,7 +28,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install -e ".[dev,generator]" + python -m pip install -e . --group dev --group generator python -m pip check - name: Lint diff --git a/README.md b/README.md index afe0264..673bed5 100644 --- a/README.md +++ b/README.md @@ -393,7 +393,7 @@ Install the development tools and pinned default OpenAPI Generator, then run the quality gates: ```bash -python -m pip install -e ".[dev,generator]" +python -m pip install -e . --group dev --group generator python scripts/generate_test_clients.py python -m pytest -v python -m ruff check . diff --git a/pyproject.toml b/pyproject.toml index de20749..bd86024 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,17 @@ dependencies = [ ] [project.optional-dependencies] +httpx = [ + "httpx>=0.28,<1", +] +aiohttp = [ + "aiohttp>=3.13,<4", +] +requests = [ + "requests>=2.33,<3", +] + +[dependency-groups] dev = [ "aiohttp>=3.13,<4", "aiohttp-retry>=2.9,<3", @@ -48,15 +59,6 @@ dev = [ "urllib3>=2.1,<3", "httpx>=0.28,<1", ] -httpx = [ - "httpx>=0.28,<1", -] -aiohttp = [ - "aiohttp>=3.13,<4", -] -requests = [ - "requests>=2.33,<3", -] generator = [ "openapi-generator-cli[jdk4py]==7.23.0", ] From 88a80ae6452f78cc1aac2db40972aaceda71d4bb Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Fri, 9 Oct 2026 23:03:46 +0530 Subject: [PATCH 28/36] Review comments: Secure OAuth handling across redirects --- README.md | 34 ++-- pyproject.toml | 1 + .../_internal/http.py | 36 +++- .../handlers/async_.py | 6 +- src/mastercard_oauth2_client/handlers/sync.py | 6 +- .../integrations/aiohttp.py | 72 +++++++ .../integrations/httpx.py | 15 ++ .../integrations/openapi/asyncio.py | 8 +- .../integrations/openapi/urllib3.py | 23 ++- .../integrations/requests.py | 36 +++- .../direct/aiohttp/test_aiohttp_client.py | 179 +++++++++++++++++- .../direct/httpx/test_httpx_async_client.py | 45 +++++ .../direct/httpx/test_httpx_sync_client.py | 39 ++++ .../direct/requests/test_requests_client.py | 34 +++- .../asyncio/test_openapi_asyncio_client.py | 23 ++- .../urllib3/test_openapi_urllib3_client.py | 48 +++++ .../support/authorization_server.py | 13 ++ tests/integration/support/models.py | 6 + tests/integration/support/resource_server.py | 9 + tests/live/direct_clients.py | 6 +- .../direct/aiohttp/test_aiohttp_middleware.py | 61 +++++- .../httpx/test_httpx_async_transport.py | 99 +++++++++- .../unit/direct/httpx/test_httpx_transport.py | 114 ++++++++++- .../direct/requests/test_requests_session.py | 50 +++++ .../asyncio/test_openapi_asyncio_adapter.py | 4 + .../httpx/test_openapi_httpx_adapter.py | 43 +++++ .../urllib3/test_openapi_urllib3_adapter.py | 66 ++++++- tests/unit/test_async_handler.py | 16 ++ tests/unit/test_handler.py | 13 ++ tests/unit/test_orchestration.py | 21 ++ 30 files changed, 1085 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 673bed5..4388fb0 100644 --- a/README.md +++ b/README.md @@ -112,9 +112,17 @@ OAuth2 and DPoP flow. To support another HTTP client, provide the matching and pass it to the handler. For higher-level use, choose a ready-made integration below. -Token requests bypass resource authentication, hooks and middleware so that -OAuth2 does not try to authenticate its own token call. Integrations still reuse -the client's relevant network and timeout settings. +### Redirects + +Token endpoint redirects are not supported and raise `OAuth2Error`. Configure +`OAuth2Config.token_endpoint` with the final token endpoint URL. + +API redirects follow the HTTP client's normal behavior. Redirects within the +same origin receive a new DPoP proof. Redirects to another origin receive no +OAuth headers. + +Note: Generated urllib3 clients do not follow redirects and return the original +`3xx` response to the caller. ### Direct HTTP Client Integration @@ -174,23 +182,20 @@ Pass a configured HTTPX transport to the wrapper for custom network settings. #### aiohttp -Install `mastercard-oauth2-client[aiohttp]`, then add `OAuth2Middleware` when -creating the session: +Install `mastercard-oauth2-client[aiohttp]`, then create an OAuth-enabled +session: ```python -import aiohttp +from mastercard_oauth2_client.integrations.aiohttp import create_oauth2_session -from mastercard_oauth2_client.integrations.aiohttp import OAuth2Middleware - -async with aiohttp.ClientSession( - middlewares=(OAuth2Middleware(oauth2_config),), -) as client: +async with create_oauth2_session(oauth2_config) as client: response = await client.get("https://api.mastercard.com/resources") response.raise_for_status() result = await response.json() ``` -Use normal `ClientSession` options for network settings. +Pass standard `aiohttp.ClientSession` options to `create_oauth2_session()` as +keyword arguments. ### OpenAPI Generated Clients @@ -268,6 +273,11 @@ finally: This library is small by design and can be extended for different application needs. +Extension instances are scoped to one OAuth2 configuration by default. If +custom token stores, scope resolvers or DPoP key providers are shared across +client identities, the application must provide appropriate data isolation and +concurrency safety. + - [`OAuth2Config`](https://github.com/Mastercard/oauth2-client-python/blob/main/src/mastercard_oauth2_client/config.py) connects credentials, endpoints and extension points. - Use [`StaticScopeResolver`](https://github.com/Mastercard/oauth2-client-python/blob/main/src/mastercard_oauth2_client/defaults.py) for diff --git a/pyproject.toml b/pyproject.toml index bd86024..d818eb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dev = [ "mypy>=1.17", "pydantic>=2.11", "pytest>=8.4", + "PySocks>=1.7.1,<2", "python-dateutil>=2.8.2", "python-dotenv>=1.1", "requests>=2.33,<3", diff --git a/src/mastercard_oauth2_client/_internal/http.py b/src/mastercard_oauth2_client/_internal/http.py index 32ac581..10b8375 100644 --- a/src/mastercard_oauth2_client/_internal/http.py +++ b/src/mastercard_oauth2_client/_internal/http.py @@ -1,8 +1,19 @@ -from collections.abc import Mapping +from collections.abc import Iterator, Mapping +from typing import Protocol, TypeAlias from urllib.parse import urlsplit _HTTP_SUCCESS_MIN = 200 _HTTP_SUCCESS_MAX_EXCLUSIVE = 300 +_HTTP_REDIRECT_MIN = 300 +_HTTP_REDIRECT_MAX_EXCLUSIVE = 400 + +HttpOrigin: TypeAlias = tuple[str, str, int] + + +class MutableHeaders(Protocol): + def __iter__(self) -> Iterator[str]: ... + + def __delitem__(self, name: str) -> None: ... def is_success_status(status: int) -> bool: @@ -11,6 +22,12 @@ def is_success_status(status: int) -> bool: return _HTTP_SUCCESS_MIN <= status < _HTTP_SUCCESS_MAX_EXCLUSIVE +def is_redirect_status(status: int) -> bool: + """Return whether an HTTP status is in the 3xx redirect range.""" + + return _HTTP_REDIRECT_MIN <= status < _HTTP_REDIRECT_MAX_EXCLUSIVE + + def is_absolute_https_url(url: str) -> bool: """Return whether a URL has an HTTPS scheme and network location.""" @@ -21,6 +38,23 @@ def is_absolute_https_url(url: str) -> bool: return False +def http_origin(url: str) -> HttpOrigin: + """Return a URL's normalized scheme, hostname, and effective port.""" + + parsed = urlsplit(url) + default_port = 443 if parsed.scheme.casefold() == "https" else 80 + return parsed.scheme.casefold(), (parsed.hostname or "").casefold(), parsed.port or default_port + + +def strip_oauth_headers(headers: MutableHeaders) -> None: + """Remove every Authorization and DPoP header, ignoring name casing.""" + + oauth_names = {"authorization", "dpop"} + matching_names = {name.casefold(): name for name in headers if name.casefold() in oauth_names} + for name in matching_names.values(): + del headers[name] + + def get_header(headers: Mapping[str, str], name: str) -> str | None: """Read an HTTP header using case-insensitive field-name matching.""" diff --git a/src/mastercard_oauth2_client/handlers/async_.py b/src/mastercard_oauth2_client/handlers/async_.py index b45cf35..8f57c57 100644 --- a/src/mastercard_oauth2_client/handlers/async_.py +++ b/src/mastercard_oauth2_client/handlers/async_.py @@ -3,7 +3,7 @@ from typing import Generic, TypeVar from .._internal._scopes import normalize_scopes -from .._internal.http import is_success_status +from .._internal.http import is_redirect_status, is_success_status from .._internal.orchestration import ( NonceStore, build_resource_headers, @@ -16,6 +16,7 @@ token_filter, ) from ..config import OAuth2Config +from ..exceptions import OAuth2Error from ..keys import validate_dpop_key from ..models import AccessToken, DPoPKey, HttpResponse from ..protocols import AsyncHttpAdapter @@ -86,6 +87,9 @@ async def _get_access_token( response = await self._send_token_request_with_retry(resource_request, adapter, scopes, dpop_key) status = adapter.response_status(response) _LOGGER.info("Token request completed with HTTP %d", status) + if is_redirect_status(status): + await adapter.close_response(response) + raise OAuth2Error("Token endpoint redirects are not supported") if not is_success_status(status): return response diff --git a/src/mastercard_oauth2_client/handlers/sync.py b/src/mastercard_oauth2_client/handlers/sync.py index 9d6d1e9..38749ec 100644 --- a/src/mastercard_oauth2_client/handlers/sync.py +++ b/src/mastercard_oauth2_client/handlers/sync.py @@ -3,7 +3,7 @@ from typing import Generic, TypeVar from .._internal._scopes import normalize_scopes -from .._internal.http import is_success_status +from .._internal.http import is_redirect_status, is_success_status from .._internal.orchestration import ( NonceStore, build_resource_headers, @@ -16,6 +16,7 @@ token_filter, ) from ..config import OAuth2Config +from ..exceptions import OAuth2Error from ..keys import validate_dpop_key from ..models import AccessToken, DPoPKey, HttpResponse from ..protocols import SyncHttpAdapter @@ -86,6 +87,9 @@ def _get_access_token( response = self._send_token_request_with_retry(resource_request, adapter, scopes, dpop_key) status = adapter.response_status(response) _LOGGER.info("Token request completed with HTTP %d", status) + if is_redirect_status(status): + adapter.close_response(response) + raise OAuth2Error("Token endpoint redirects are not supported") if not is_success_status(status): return response diff --git a/src/mastercard_oauth2_client/integrations/aiohttp.py b/src/mastercard_oauth2_client/integrations/aiohttp.py index 24aa9bd..a6fe9fe 100644 --- a/src/mastercard_oauth2_client/integrations/aiohttp.py +++ b/src/mastercard_oauth2_client/integrations/aiohttp.py @@ -1,10 +1,15 @@ from __future__ import annotations from collections.abc import Mapping +from contextvars import ContextVar, Token +from types import SimpleNamespace +from typing import Any import aiohttp +from .._internal.http import HttpOrigin, http_origin, strip_oauth_headers from ..config import OAuth2Config +from ..exceptions import OAuth2Error from ..handlers import AsyncOAuth2Handler from ..models import HttpRequest @@ -36,6 +41,7 @@ async def send_token_request( data=request.body, raise_for_status=False, middlewares=(), + allow_redirects=False, ) async def send_resource_request( @@ -68,6 +74,16 @@ def __init__(self, config: OAuth2Config) -> None: """Create middleware using the supplied OAuth2 configuration.""" self._handler = AsyncOAuth2Handler[aiohttp.ClientRequest, aiohttp.ClientResponse](config) + self._original_origin: ContextVar[HttpOrigin | None] = ContextVar( + f"oauth2_original_origin_{id(self)}", + default=None, + ) + # aiohttp invokes middleware separately for each redirect. Tracing + # records the first request's origin so later hops can be checked. + self.trace_config = aiohttp.TraceConfig() + self.trace_config.on_request_start.append(self._on_request_start) + self.trace_config.on_request_end.append(self._on_request_end) + self.trace_config.on_request_exception.append(self._on_request_exception) async def __call__( self, @@ -76,8 +92,64 @@ async def __call__( ) -> aiohttp.ClientResponse: """Buffer a replayable request and execute the OAuth2 flow.""" + if self._is_cross_origin_redirect(request): + strip_oauth_headers(request.headers) + return await handler(request) if isinstance(request.body, aiohttp.payload.Payload): body = await request.body.as_bytes() request.chunked = None await request.update_body(body) return await self._handler.execute(request, _AiohttpAdapter(handler)) + + def _is_cross_origin_redirect(self, request: aiohttp.ClientRequest) -> bool: + original_origin = self._original_origin.get() + if original_origin is None: + raise RuntimeError( + "OAuth2Middleware.trace_config must be installed in ClientSession trace_configs" + ) + return http_origin(str(request.url)) != original_origin + + async def _on_request_start( + self, + session: aiohttp.ClientSession, + trace_config_ctx: SimpleNamespace, + params: aiohttp.TraceRequestStartParams, + ) -> None: + token = self._original_origin.set(http_origin(str(params.url))) + trace_config_ctx.oauth2_origin_token = token + + async def _on_request_end( + self, + session: aiohttp.ClientSession, + trace_config_ctx: SimpleNamespace, + params: aiohttp.TraceRequestEndParams, + ) -> None: + self._reset_original_origin(trace_config_ctx) + + async def _on_request_exception( + self, + session: aiohttp.ClientSession, + trace_config_ctx: SimpleNamespace, + params: aiohttp.TraceRequestExceptionParams, + ) -> None: + self._reset_original_origin(trace_config_ctx) + + def _reset_original_origin(self, trace_config_ctx: SimpleNamespace) -> None: + token = getattr(trace_config_ctx, "oauth2_origin_token", None) + if isinstance(token, Token): + self._original_origin.reset(token) + + +def create_oauth2_session(config: OAuth2Config, **session_kwargs: Any) -> aiohttp.ClientSession: + """Create an aiohttp session with OAuth middleware and redirect tracing.""" + + existing_middlewares = tuple(session_kwargs.pop("middlewares", ()) or ()) + if any(isinstance(middleware, OAuth2Middleware) for middleware in existing_middlewares): + raise OAuth2Error("OAuth2 middleware is already configured") + existing_trace_configs = list(session_kwargs.pop("trace_configs", ()) or ()) + oauth2 = OAuth2Middleware(config) + return aiohttp.ClientSession( + **session_kwargs, + middlewares=(oauth2, *existing_middlewares), + trace_configs=[oauth2.trace_config, *existing_trace_configs], + ) diff --git a/src/mastercard_oauth2_client/integrations/httpx.py b/src/mastercard_oauth2_client/integrations/httpx.py index 5f4a757..b6ba91d 100644 --- a/src/mastercard_oauth2_client/integrations/httpx.py +++ b/src/mastercard_oauth2_client/integrations/httpx.py @@ -4,10 +4,13 @@ import httpx +from .._internal.http import http_origin, strip_oauth_headers from ..config import OAuth2Config from ..handlers import AsyncOAuth2Handler, OAuth2Handler from ..models import HttpRequest +_ORIGINAL_ORIGIN_EXTENSION = "com.mastercard.oauth2.original_origin" + class _HttpxAdapterBase: """Map native HTTPX request and response metadata into handler operations.""" @@ -100,6 +103,9 @@ def handle_request(self, request: httpx.Request) -> httpx.Response: """Buffer a replayable request and execute the OAuth2 flow.""" request.read() + if _is_cross_origin_redirect(request): + strip_oauth_headers(request.headers) + return self._transport.handle_request(request) return self._handler.execute(request, self._adapter) def close(self) -> None: @@ -122,9 +128,18 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response: """Buffer a replayable request and execute the asynchronous OAuth2 flow.""" await request.aread() + if _is_cross_origin_redirect(request): + strip_oauth_headers(request.headers) + return await self._transport.handle_async_request(request) return await self._handler.execute(request, self._adapter) async def aclose(self) -> None: """Close the wrapped asynchronous HTTPX transport.""" await self._transport.aclose() + + +def _is_cross_origin_redirect(request: httpx.Request) -> bool: + current_origin = http_origin(str(request.url)) + original_origin = request.extensions.setdefault(_ORIGINAL_ORIGIN_EXTENSION, current_origin) + return not isinstance(original_origin, tuple) or original_origin != current_origin diff --git a/src/mastercard_oauth2_client/integrations/openapi/asyncio.py b/src/mastercard_oauth2_client/integrations/openapi/asyncio.py index c4ac8d9..b646e95 100644 --- a/src/mastercard_oauth2_client/integrations/openapi/asyncio.py +++ b/src/mastercard_oauth2_client/integrations/openapi/asyncio.py @@ -38,5 +38,11 @@ def add_oauth2_layer(api_client: _ApiClient, config: OAuth2Config) -> None: existing_middlewares = cast(Sequence[aiohttp.ClientMiddlewareType], session_kwargs.get("middlewares") or ()) if any(isinstance(middleware, OAuth2Middleware) for middleware in existing_middlewares): raise OAuth2Error("OAuth2 is already attached to the generated asyncio client") - session_kwargs["middlewares"] = (OAuth2Middleware(config), *existing_middlewares) + middleware = OAuth2Middleware(config) + configured_trace_configs = session_kwargs.get("trace_configs") + if configured_trace_configs is None: + configured_trace_configs = getattr(rest_client.configuration, "trace_configs", None) + existing_trace_configs = cast(Sequence[aiohttp.TraceConfig], configured_trace_configs or ()) + session_kwargs["middlewares"] = (middleware, *existing_middlewares) + session_kwargs["trace_configs"] = (middleware.trace_config, *existing_trace_configs) rest_client.configuration.client_session_kwargs = session_kwargs diff --git a/src/mastercard_oauth2_client/integrations/openapi/urllib3.py b/src/mastercard_oauth2_client/integrations/openapi/urllib3.py index ba75b7d..390bd74 100644 --- a/src/mastercard_oauth2_client/integrations/openapi/urllib3.py +++ b/src/mastercard_oauth2_client/integrations/openapi/urllib3.py @@ -4,9 +4,11 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass from functools import wraps -from typing import Any, Protocol, TypeAlias +from typing import Any, Protocol, TypeAlias, runtime_checkable from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +from urllib3.util import Retry + from ...config import OAuth2Config from ...exceptions import OAuth2Error from ...handlers import OAuth2Handler @@ -39,6 +41,11 @@ class _RestClient(Protocol): def request(self, method: str, url: str, **kwargs: Any) -> _GeneratedResponse: ... +@runtime_checkable +class _PoolManager(Protocol): + connection_pool_kw: dict[str, Any] + + class _ApiClient(Protocol): @property def rest_client(self) -> _RestClient: ... @@ -137,6 +144,13 @@ def add_oauth2_layer(api_client: _ApiClient, config: OAuth2Config) -> None: raise OAuth2Error("OAuth2 is already attached to the generated urllib3 client") adapter = _OpenApiAdapter(original_request) handler = OAuth2Handler[_OpenApiRequest, _GeneratedResponse](config) + pool_manager = getattr(api_client.rest_client, "pool_manager", None) + if isinstance(pool_manager, _PoolManager): + # urllib3 follows redirects internally without calling + # authenticated_request() again. It would therefore reuse the DPoP + # proof created for the original URL, which the redirect target may + # reject. Disable automatic redirects to avoid sending a stale proof. + _disable_redirects(pool_manager) @wraps(original_request) def authenticated_request(method: str, url: str, **kwargs: Any) -> _GeneratedResponse: @@ -156,6 +170,13 @@ def authenticated_request(method: str, url: str, **kwargs: Any) -> _GeneratedRes api_client.rest_client.request = authenticated_request # type: ignore[method-assign] +def _disable_redirects(pool_manager: _PoolManager) -> None: + retries = pool_manager.connection_pool_kw.get("retries", Retry.DEFAULT) + if not isinstance(retries, Retry): + retries = Retry.from_int(retries) + pool_manager.connection_pool_kw["retries"] = retries.new(redirect=False) + + def _url_with_query(url: str, query_params: object) -> str: if not query_params: return url diff --git a/src/mastercard_oauth2_client/integrations/requests.py b/src/mastercard_oauth2_client/integrations/requests.py index 736a7f5..4ec326e 100644 --- a/src/mastercard_oauth2_client/integrations/requests.py +++ b/src/mastercard_oauth2_client/integrations/requests.py @@ -1,14 +1,17 @@ from __future__ import annotations -from collections.abc import Mapping -from typing import Any +from collections.abc import Callable, Mapping +from typing import Any, cast import requests +from .._internal.http import strip_oauth_headers from ..config import OAuth2Config from ..handlers import OAuth2Handler from ..models import HttpRequest +_SKIP_OAUTH = "_mastercard_oauth2_skip" + class _RequestsAdapter: """Map native Requests messages into synchronous handler operations.""" @@ -44,6 +47,7 @@ def send_token_request( headers=dict(request.headers), data=request.body, timeout=self._resource_settings.get("timeout"), + allow_redirects=False, ) def _sync_token_session(self) -> None: @@ -90,10 +94,38 @@ def __init__(self, config: OAuth2Config) -> None: def send(self, request: requests.PreparedRequest, **kwargs: Any) -> requests.Response: """Authenticate and send one prepared resource request.""" + if getattr(request, _SKIP_OAUTH, False): + strip_oauth_headers(request.headers) + return super().send(request, **kwargs) _reject_streaming_body(request) adapter = _RequestsAdapter(self, self._token_session, kwargs) return self._handler.execute(request, adapter) + def rebuild_auth( + self, + prepared_request: requests.PreparedRequest, + response: requests.Response, + ) -> None: + """Strip OAuth headers after a redirect crosses an origin boundary.""" + + base_rebuild_auth = cast( + Callable[[requests.PreparedRequest, requests.Response], None], + super().rebuild_auth, + ) + base_rebuild_auth(prepared_request, response) + previous_request = response.request + should_strip_auth = cast(Callable[[str, str], bool], self.should_strip_auth) + # Disable OAuth when this hop crosses origins, or when an earlier hop + # already crossed origins and marked the previous request. Propagating + # the marker keeps OAuth off for the rest of that redirect chain. + crossed_origin = getattr(previous_request, _SKIP_OAUTH, False) or should_strip_auth( + previous_request.url or "", + prepared_request.url or "", + ) + if crossed_origin: + strip_oauth_headers(prepared_request.headers) + setattr(prepared_request, _SKIP_OAUTH, True) + def _reject_streaming_body(request: requests.PreparedRequest) -> None: # The server may ask us to send the request again with a new DPoP proof. diff --git a/tests/integration/direct/aiohttp/test_aiohttp_client.py b/tests/integration/direct/aiohttp/test_aiohttp_client.py index b6d7386..5a4ff2e 100644 --- a/tests/integration/direct/aiohttp/test_aiohttp_client.py +++ b/tests/integration/direct/aiohttp/test_aiohttp_client.py @@ -13,13 +13,13 @@ import aiohttp_retry import pytest -from mastercard_oauth2_client import OAuth2Config -from mastercard_oauth2_client.integrations.aiohttp import OAuth2Middleware +from mastercard_oauth2_client import OAuth2Config, OAuth2Error +from mastercard_oauth2_client.integrations.aiohttp import create_oauth2_session from tests.integration.support.environment import OAuthTestEnvironment from tests.integration.support.lifecycle import run_async_resource_lifecycle from tests.integration.support.lifecycle_clients.aiohttp import AiohttpLifecycleClient from tests.integration.support.models import DPoPAlgorithm -from tests.integration.support.resource_server import PAYLOAD_ECHO_PATH +from tests.integration.support.resource_server import PAYLOAD_ECHO_PATH, REDIRECT_PATH from tests.integration.support.stores import InvalidatableTokenStore @@ -30,9 +30,9 @@ def _client( config: OAuth2Config | None = None, ) -> aiohttp.ClientSession: ssl_context = ssl.create_default_context(cafile=str(environment.tls_files.certificate)) - return aiohttp.ClientSession( + return create_oauth2_session( + config or environment.config(algorithm), connector=aiohttp.TCPConnector(ssl=ssl_context), - middlewares=(OAuth2Middleware(config or environment.config(algorithm)),), ) @@ -278,6 +278,27 @@ async def exercise() -> tuple[aiohttp.ClientResponse, dict[str, str]]: assert oauth_environment.resource.state.requests == [] +@pytest.mark.parametrize("status", [307, 308]) +def test_aiohttp_client_rejects_token_redirect( + oauth_environment: OAuthTestEnvironment, + status: int, +) -> None: + async def exercise() -> None: + async with _client(oauth_environment) as client: + await client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + + oauth_environment.authorization.state.token_redirect_status = status + + with pytest.raises(OAuth2Error, match="Token endpoint redirects are not supported"): + asyncio.run(exercise()) + + assert len(oauth_environment.authorization.state.requests) == 1 + assert oauth_environment.resource.state.requests == [] + + def test_aiohttp_client_returns_resource_server_error(oauth_environment: OAuthTestEnvironment) -> None: async def exercise() -> tuple[int, dict[str, str], str | None]: async with _client(oauth_environment) as client: @@ -329,7 +350,146 @@ async def exercise() -> bytes: assert asyncio.run(exercise()) == b'{"error":"not_found"}' -def test_aiohttp_middleware_order_exposes_each_resource_retry( +def test_aiohttp_client_does_not_authenticate_cross_origin_redirect( + oauth_environment: OAuthTestEnvironment, +) -> None: + redirected_url = oauth_environment.resource.base_url.replace("127.0.0.1", "localhost") + oauth_environment.resource.state.redirect_location = f"{redirected_url}/api/resources/missing" + + async def exercise() -> int: + async with _client(oauth_environment) as client: + response = await client.get( + f"{oauth_environment.resource.base_url}{REDIRECT_PATH}", + headers={"Accept": "application/json"}, + ) + await response.read() + return response.status + + assert asyncio.run(exercise()) == 401 + redirected_request = oauth_environment.resource.state.requests[-1] + assert redirected_request.header("host").startswith("localhost:") + assert redirected_request.header("authorization") is None + assert redirected_request.header("dpop") is None + assert redirected_request.header("cookie") is None + + +def test_aiohttp_client_keeps_oauth_off_after_cross_origin_redirect( + oauth_environment: OAuthTestEnvironment, +) -> None: + redirected_url = oauth_environment.resource.base_url.replace("127.0.0.1", "localhost") + oauth_environment.resource.state.unauthenticated_redirect_locations.update( + { + REDIRECT_PATH: f"{redirected_url}/api/redirect-next", + "/api/redirect-next": f"{redirected_url}/api/resources/missing", + } + ) + + async def exercise() -> int: + async with _client(oauth_environment) as client: + response = await client.get( + f"{oauth_environment.resource.base_url}{REDIRECT_PATH}", + headers={"Accept": "application/json"}, + ) + await response.read() + return response.status + + assert asyncio.run(exercise()) == 401 + redirected_requests = oauth_environment.resource.state.requests[-2:] + assert [request.header("host").split(":")[0] for request in redirected_requests] == [ + "localhost", + "localhost", + ] + assert all(request.header("authorization") is None for request in redirected_requests) + assert all(request.header("dpop") is None for request in redirected_requests) + + +def test_aiohttp_client_isolates_concurrent_redirect_origins( + oauth_environment: OAuthTestEnvironment, +) -> None: + base_url = oauth_environment.resource.base_url + redirected_url = base_url.replace("127.0.0.1", "localhost") + oauth_environment.resource.state.redirect_locations.update( + { + "/api/redirect-same": f"{base_url}/api/resources/same-missing", + "/api/redirect-cross": f"{redirected_url}/api/resources/cross-missing", + } + ) + + async def exercise() -> tuple[int, int]: + async with _client(oauth_environment) as client: + same_origin, cross_origin = await asyncio.gather( + client.get(f"{base_url}/api/redirect-same", headers={"Accept": "application/json"}), + client.get(f"{base_url}/api/redirect-cross", headers={"Accept": "application/json"}), + ) + await asyncio.gather(same_origin.read(), cross_origin.read()) + return same_origin.status, cross_origin.status + + assert asyncio.run(exercise()) == (404, 401) + same_origin_request = next( + request + for request in oauth_environment.resource.state.requests + if request.path == "/api/resources/same-missing" + ) + cross_origin_request = next( + request + for request in oauth_environment.resource.state.requests + if request.path == "/api/resources/cross-missing" + ) + assert same_origin_request.header("authorization") is not None + assert same_origin_request.header("dpop") is not None + assert cross_origin_request.header("authorization") is None + assert cross_origin_request.header("dpop") is None + + +def test_aiohttp_client_authenticates_same_origin_redirect( + oauth_environment: OAuthTestEnvironment, +) -> None: + oauth_environment.resource.state.redirect_location = ( + f"{oauth_environment.resource.base_url}/api/resources/missing" + ) + + async def exercise() -> int: + async with _client(oauth_environment) as client: + response = await client.get( + f"{oauth_environment.resource.base_url}{REDIRECT_PATH}", + headers={"Accept": "application/json"}, + ) + await response.read() + return response.status + + assert asyncio.run(exercise()) == 404 + redirected_request = oauth_environment.resource.state.requests[-1] + assert redirected_request.header("authorization") is not None + assert redirected_request.header("dpop") is not None + + +def test_aiohttp_client_authenticates_later_explicit_request_to_redirect_origin( + oauth_environment: OAuthTestEnvironment, +) -> None: + redirected_url = oauth_environment.resource.base_url.replace("127.0.0.1", "localhost") + oauth_environment.resource.state.redirect_location = f"{redirected_url}/api/resources/missing" + + async def exercise() -> tuple[int, int]: + async with _client(oauth_environment) as client: + redirect_response = await client.get( + f"{oauth_environment.resource.base_url}{REDIRECT_PATH}", + headers={"Accept": "application/json"}, + ) + await redirect_response.read() + explicit_response = await client.get( + f"{redirected_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + await explicit_response.read() + return redirect_response.status, explicit_response.status + + assert asyncio.run(exercise()) == (401, 404) + explicit_request = oauth_environment.resource.state.requests[-1] + assert explicit_request.header("authorization") is not None + assert explicit_request.header("dpop") is not None + + +def test_aiohttp_session_factory_preserves_caller_middleware_order( oauth_environment: OAuthTestEnvironment, ) -> None: async def exercise() -> tuple[list[str], list[str]]: @@ -345,9 +505,10 @@ async def inner(request: aiohttp.ClientRequest, handler: aiohttp.ClientHandlerTy return await handler(request) ssl_context = ssl.create_default_context(cafile=str(oauth_environment.tls_files.certificate)) - async with aiohttp.ClientSession( + async with create_oauth2_session( + oauth_environment.config("ec"), connector=aiohttp.TCPConnector(ssl=ssl_context), - middlewares=(outer, OAuth2Middleware(oauth_environment.config("ec")), inner), + middlewares=(outer, inner), ) as client: response = await client.get( f"{oauth_environment.resource.base_url}/api/resources/missing", @@ -359,7 +520,7 @@ async def inner(request: aiohttp.ClientRequest, handler: aiohttp.ClientHandlerTy outer_calls, inner_calls = asyncio.run(exercise()) resource_url = f"{oauth_environment.resource.base_url}/api/resources/missing" - assert outer_calls == [resource_url] + assert outer_calls == [resource_url, resource_url] assert inner_calls == [resource_url, resource_url] diff --git a/tests/integration/direct/httpx/test_httpx_async_client.py b/tests/integration/direct/httpx/test_httpx_async_client.py index f8a0310..39e0fc9 100644 --- a/tests/integration/direct/httpx/test_httpx_async_client.py +++ b/tests/integration/direct/httpx/test_httpx_async_client.py @@ -12,6 +12,7 @@ from tests.integration.support.lifecycle import run_async_resource_lifecycle from tests.integration.support.lifecycle_clients.httpx import AsyncHttpxLifecycleClient from tests.integration.support.models import DPoPAlgorithm, RecordedRequest +from tests.integration.support.resource_server import REDIRECT_PATH from tests.integration.support.stores import InvalidatableTokenStore @@ -72,6 +73,50 @@ async def exercise() -> None: assert [request.method for request in oauth_environment.resource.state.requests][-2:] == ["GET", "DELETE"] +def test_async_httpx_client_does_not_authenticate_cross_origin_redirect( + oauth_environment: OAuthTestEnvironment, +) -> None: + redirected_url = oauth_environment.resource.base_url.replace("127.0.0.1", "localhost") + oauth_environment.resource.state.redirect_location = f"{redirected_url}/api/resources/missing" + + async def exercise() -> int: + async with _client(oauth_environment) as client: + response = await client.get( + f"{oauth_environment.resource.base_url}{REDIRECT_PATH}", + headers={"Accept": "application/json"}, + follow_redirects=True, + ) + return response.status_code + + assert asyncio.run(exercise()) == 401 + redirected_request = oauth_environment.resource.state.requests[-1] + assert redirected_request.header("host").startswith("localhost:") + assert redirected_request.header("authorization") is None + assert redirected_request.header("dpop") is None + + +def test_async_httpx_client_authenticates_same_origin_redirect( + oauth_environment: OAuthTestEnvironment, +) -> None: + oauth_environment.resource.state.redirect_location = ( + f"{oauth_environment.resource.base_url}/api/resources/missing" + ) + + async def exercise() -> int: + async with _client(oauth_environment) as client: + response = await client.get( + f"{oauth_environment.resource.base_url}{REDIRECT_PATH}", + headers={"Accept": "application/json"}, + follow_redirects=True, + ) + return response.status_code + + assert asyncio.run(exercise()) == 404 + redirected_request = oauth_environment.resource.state.requests[-1] + assert redirected_request.header("authorization") is not None + assert redirected_request.header("dpop") is not None + + def test_async_httpx_client_preserves_query_json_and_caller_headers( oauth_environment: OAuthTestEnvironment, ) -> None: diff --git a/tests/integration/direct/httpx/test_httpx_sync_client.py b/tests/integration/direct/httpx/test_httpx_sync_client.py index 2797642..5f01bec 100644 --- a/tests/integration/direct/httpx/test_httpx_sync_client.py +++ b/tests/integration/direct/httpx/test_httpx_sync_client.py @@ -11,6 +11,7 @@ from tests.integration.support.lifecycle import run_resource_lifecycle from tests.integration.support.lifecycle_clients.httpx import HttpxLifecycleClient from tests.integration.support.models import DPoPAlgorithm +from tests.integration.support.resource_server import REDIRECT_PATH from tests.integration.support.stores import InvalidatableTokenStore @@ -226,6 +227,44 @@ def test_httpx_client_preserves_resource_event_hooks(oauth_environment: OAuthTes assert response_statuses == [404] +def test_httpx_client_does_not_authenticate_cross_origin_redirect( + oauth_environment: OAuthTestEnvironment, +) -> None: + redirected_url = oauth_environment.resource.base_url.replace("127.0.0.1", "localhost") + oauth_environment.resource.state.redirect_location = f"{redirected_url}/api/resources/missing" + + with _client(oauth_environment) as client: + response = client.get( + f"{oauth_environment.resource.base_url}{REDIRECT_PATH}", + headers={"Accept": "application/json"}, + follow_redirects=True, + ) + + assert response.status_code == 401 + redirected_request = oauth_environment.resource.state.requests[-1] + assert redirected_request.header("host").startswith("localhost:") + assert redirected_request.header("authorization") is None + assert redirected_request.header("dpop") is None + + +def test_httpx_client_authenticates_same_origin_redirect(oauth_environment: OAuthTestEnvironment) -> None: + oauth_environment.resource.state.redirect_location = ( + f"{oauth_environment.resource.base_url}/api/resources/missing" + ) + + with _client(oauth_environment) as client: + response = client.get( + f"{oauth_environment.resource.base_url}{REDIRECT_PATH}", + headers={"Accept": "application/json"}, + follow_redirects=True, + ) + + assert response.status_code == 404 + redirected_request = oauth_environment.resource.state.requests[-1] + assert redirected_request.header("authorization") is not None + assert redirected_request.header("dpop") is not None + + def test_httpx_transport_closes_wrapped_transport(oauth_environment: OAuthTestEnvironment) -> None: class TrackingTransport(httpx.BaseTransport): closed = False diff --git a/tests/integration/direct/requests/test_requests_client.py b/tests/integration/direct/requests/test_requests_client.py index cb030cc..0422af8 100644 --- a/tests/integration/direct/requests/test_requests_client.py +++ b/tests/integration/direct/requests/test_requests_client.py @@ -257,7 +257,7 @@ def test_requests_client_preserves_resource_response_hooks(oauth_environment: OA assert statuses == [401, 404] -def test_requests_client_reauthenticates_cross_origin_redirect_without_leaking_cookies( +def test_requests_client_does_not_authenticate_cross_origin_redirect( oauth_environment: OAuthTestEnvironment, ) -> None: redirected_url = oauth_environment.resource.base_url.replace("127.0.0.1", "localhost") @@ -269,7 +269,7 @@ def test_requests_client_reauthenticates_cross_origin_redirect_without_leaking_c headers={"Accept": "application/json"}, ) - assert response.status_code == 404 + assert response.status_code == 401 assert [entry.status_code for entry in response.history] == [307] assert [request.path for request in oauth_environment.resource.state.requests] == [ REDIRECT_PATH, @@ -278,9 +278,39 @@ def test_requests_client_reauthenticates_cross_origin_redirect_without_leaking_c ] redirected_request = oauth_environment.resource.state.requests[-1] assert redirected_request.header("host").startswith("localhost:") + assert redirected_request.header("authorization") is None + assert redirected_request.header("dpop") is None assert redirected_request.header("cookie") is None +def test_requests_client_keeps_oauth_off_after_cross_origin_redirect( + oauth_environment: OAuthTestEnvironment, +) -> None: + redirected_url = oauth_environment.resource.base_url.replace("127.0.0.1", "localhost") + oauth_environment.resource.state.unauthenticated_redirect_locations.update( + { + REDIRECT_PATH: f"{redirected_url}/api/redirect-next", + "/api/redirect-next": f"{redirected_url}/api/resources/missing", + } + ) + + with _client(oauth_environment) as client: + response = client.get( + f"{oauth_environment.resource.base_url}{REDIRECT_PATH}", + headers={"Accept": "application/json"}, + ) + + assert response.status_code == 401 + assert [entry.status_code for entry in response.history] == [307, 307] + redirected_requests = oauth_environment.resource.state.requests[-2:] + assert [request.header("host").split(":")[0] for request in redirected_requests] == [ + "localhost", + "localhost", + ] + assert all(request.header("authorization") is None for request in redirected_requests) + assert all(request.header("dpop") is None for request in redirected_requests) + + def test_requests_client_preserves_cookies_on_same_origin_redirect( oauth_environment: OAuthTestEnvironment, ) -> None: diff --git a/tests/integration/openapi/asyncio/test_openapi_asyncio_client.py b/tests/integration/openapi/asyncio/test_openapi_asyncio_client.py index f3fe344..ab8f934 100644 --- a/tests/integration/openapi/asyncio/test_openapi_asyncio_client.py +++ b/tests/integration/openapi/asyncio/test_openapi_asyncio_client.py @@ -18,7 +18,7 @@ ) from generated_asyncio_test_client.exceptions import ApiException # type: ignore[import-not-found] -from mastercard_oauth2_client import OAuth2Config +from mastercard_oauth2_client import OAuth2Config, OAuth2Error from mastercard_oauth2_client.integrations.openapi.asyncio import add_oauth2_layer from tests.integration.support.environment import OAuthTestEnvironment from tests.integration.support.lifecycle import run_async_resource_lifecycle @@ -140,6 +140,27 @@ async def exercise() -> tuple[aiohttp.ClientResponse, str]: assert resource_id == "1" +@pytest.mark.parametrize("status", [307, 308]) +def test_openapi_asyncio_client_rejects_token_redirect( + oauth_environment: OAuthTestEnvironment, + status: int, +) -> None: + async def exercise() -> None: + client = _client(oauth_environment) + try: + await client.api.get_resource("missing") + finally: + await client.close() + + oauth_environment.authorization.state.token_redirect_status = status + + with pytest.raises(OAuth2Error, match="Token endpoint redirects are not supported"): + asyncio.run(exercise()) + + assert len(oauth_environment.authorization.state.requests) == 1 + assert oauth_environment.resource.state.requests == [] + + def test_openapi_asyncio_client_reuses_cached_token(oauth_environment: OAuthTestEnvironment) -> None: async def exercise() -> None: client = _client(oauth_environment) diff --git a/tests/integration/openapi/urllib3/test_openapi_urllib3_client.py b/tests/integration/openapi/urllib3/test_openapi_urllib3_client.py index 054e2e3..355f258 100644 --- a/tests/integration/openapi/urllib3/test_openapi_urllib3_client.py +++ b/tests/integration/openapi/urllib3/test_openapi_urllib3_client.py @@ -14,6 +14,7 @@ from tests.integration.support.lifecycle import run_resource_lifecycle from tests.integration.support.lifecycle_clients.openapi_urllib3 import OpenApiLifecycleClient from tests.integration.support.models import DPoPAlgorithm +from tests.integration.support.resource_server import REDIRECT_PATH from tests.integration.support.stores import InvalidatableTokenStore @@ -63,6 +64,53 @@ def test_openapi_lifecycle_attempts_delete_after_get_failure(oauth_environment: assert [request.method for request in oauth_environment.resource.state.requests][-2:] == ["GET", "DELETE"] +def test_openapi_client_does_not_follow_cross_origin_redirect( + oauth_environment: OAuthTestEnvironment, +) -> None: + redirected_url = oauth_environment.resource.base_url.replace("127.0.0.1", "localhost") + oauth_environment.resource.state.redirect_location = f"{redirected_url}/api/resources/missing" + client = _client(oauth_environment) + + response = client.api.api_client.rest_client.request( + "GET", + f"{oauth_environment.resource.base_url}{REDIRECT_PATH}", + headers={"Accept": "application/json"}, + ) + response.read() + + assert response.status == 307 + assert [request.path for request in oauth_environment.resource.state.requests] == [ + REDIRECT_PATH, + REDIRECT_PATH, + ] + assert all( + request.header("host").startswith("127.0.0.1:") + for request in oauth_environment.resource.state.requests + ) + + +def test_openapi_client_does_not_follow_same_origin_redirect( + oauth_environment: OAuthTestEnvironment, +) -> None: + oauth_environment.resource.state.redirect_location = ( + f"{oauth_environment.resource.base_url}/api/resources/missing" + ) + client = _client(oauth_environment) + + response = client.api.api_client.rest_client.request( + "GET", + f"{oauth_environment.resource.base_url}{REDIRECT_PATH}", + headers={"Accept": "application/json"}, + ) + response.read() + + assert response.status == 307 + assert [request.path for request in oauth_environment.resource.state.requests] == [ + REDIRECT_PATH, + REDIRECT_PATH, + ] + + def test_openapi_client_preserves_query_json_and_caller_headers(oauth_environment: OAuthTestEnvironment) -> None: client = _client(oauth_environment) client.api.api_client.user_agent = "caller-agent" diff --git a/tests/integration/support/authorization_server.py b/tests/integration/support/authorization_server.py index 2239587..d904746 100644 --- a/tests/integration/support/authorization_server.py +++ b/tests/integration/support/authorization_server.py @@ -43,6 +43,12 @@ def do_POST(self) -> None: if self.path != TOKEN_PATH: self._send_json(404, {"error": "not_found"}) return + if server.state.token_redirect_status is not None: + self._send_empty( + server.state.token_redirect_status, + {"Location": "/oauth/redirect-target"}, + ) + return try: self._validate_request(server.state, request) except JwtValidationError as error: @@ -131,6 +137,13 @@ def _send_json(self, status: int, value: object, headers: Mapping[str, str] | No self.end_headers() self.wfile.write(body) + def _send_empty(self, status: int, headers: Mapping[str, str] | None = None) -> None: + self.send_response(status) + self.send_header("Content-Length", "0") + for name, header_value in (headers or {}).items(): + self.send_header(name, header_value) + self.end_headers() + class FakeAuthorizationServer: """Local HTTPS token endpoint with configurable deterministic test state.""" diff --git a/tests/integration/support/models.py b/tests/integration/support/models.py index 3728860..e8dd456 100644 --- a/tests/integration/support/models.py +++ b/tests/integration/support/models.py @@ -38,6 +38,7 @@ class AuthorizationState: reject_client_assertion: bool = False always_challenge_nonce: bool = False initial_token_request_barrier: Barrier | None = None + token_redirect_status: int | None = None def reset(self) -> None: self.requests.clear() @@ -45,6 +46,7 @@ def reset(self) -> None: self.reject_client_assertion = False self.always_challenge_nonce = False self.initial_token_request_barrier = None + self.token_redirect_status = None @dataclass(slots=True) @@ -59,6 +61,8 @@ class ResourceState: always_challenge_nonce: bool = False fail_get: bool = False redirect_location: str | None = None + redirect_locations: dict[str, str] = field(default_factory=dict) + unauthenticated_redirect_locations: dict[str, str] = field(default_factory=dict) def reset(self) -> None: self.requests.clear() @@ -68,6 +72,8 @@ def reset(self) -> None: self.always_challenge_nonce = False self.fail_get = False self.redirect_location = None + self.redirect_locations.clear() + self.unauthenticated_redirect_locations.clear() DPoPAlgorithm: TypeAlias = Literal["ec", "rsa"] diff --git a/tests/integration/support/resource_server.py b/tests/integration/support/resource_server.py index 77ade14..ea60159 100644 --- a/tests/integration/support/resource_server.py +++ b/tests/integration/support/resource_server.py @@ -51,6 +51,11 @@ def _handle(self) -> None: raise TypeError("Unexpected resource server") request = _record_request(self) server.state.requests.append(request) + path = urlsplit(request.path).path + redirect_location = server.state.unauthenticated_redirect_locations.get(path) + if redirect_location is not None: + self._send_empty(307, {"Location": redirect_location}) + return try: self._validate_request(server.state, request) except JwtValidationError as error: @@ -112,6 +117,10 @@ def _validate_request(self, state: ResourceState, request: RecordedRequest) -> N def _handle_resource(self, state: ResourceState, request: RecordedRequest) -> None: path = urlsplit(request.path).path + redirect_location = state.redirect_locations.get(path) + if redirect_location is not None: + self._send_empty(307, {"Location": redirect_location, "Set-Cookie": "redirected=true; Path=/"}) + return if path == REDIRECT_PATH and state.redirect_location is not None: self._send_empty(307, {"Location": state.redirect_location, "Set-Cookie": "redirected=true; Path=/"}) return diff --git a/tests/live/direct_clients.py b/tests/live/direct_clients.py index a157a76..2679176 100644 --- a/tests/live/direct_clients.py +++ b/tests/live/direct_clients.py @@ -11,7 +11,7 @@ import requests from mastercard_oauth2_client import OAuth2Config -from mastercard_oauth2_client.integrations.aiohttp import OAuth2Middleware +from mastercard_oauth2_client.integrations.aiohttp import create_oauth2_session from mastercard_oauth2_client.integrations.httpx import AsyncOAuth2Transport, OAuth2Transport from mastercard_oauth2_client.integrations.requests import OAuth2Session @@ -66,9 +66,9 @@ def run_aiohttp(settings: LiveSettings, config: OAuth2Config, scenario: str) -> async def _run_aiohttp(settings: LiveSettings, config: OAuth2Config, scenario: str) -> None: ssl_context = ssl.create_default_context(cafile=settings.ca_certificate) - async with aiohttp.ClientSession( + async with create_oauth2_session( + config, connector=aiohttp.TCPConnector(ssl=ssl_context), - middlewares=(OAuth2Middleware(config),), ) as client: await _run_async_scenario(_AiohttpOperations(client, settings.api_base_url, settings.proxy), scenario) diff --git a/tests/unit/direct/aiohttp/test_aiohttp_middleware.py b/tests/unit/direct/aiohttp/test_aiohttp_middleware.py index 5fd24fb..4f06f7e 100644 --- a/tests/unit/direct/aiohttp/test_aiohttp_middleware.py +++ b/tests/unit/direct/aiohttp/test_aiohttp_middleware.py @@ -6,11 +6,14 @@ from typing import Any, cast import aiohttp +import pytest from multidict import CIMultiDict +import mastercard_oauth2_client.integrations.aiohttp as aiohttp_integration +from mastercard_oauth2_client import OAuth2Error from mastercard_oauth2_client._internal.orchestration import has_nonce_challenge_header from mastercard_oauth2_client.handlers import AsyncOAuth2Handler -from mastercard_oauth2_client.integrations.aiohttp import OAuth2Middleware, _AiohttpAdapter +from mastercard_oauth2_client.integrations.aiohttp import OAuth2Middleware, _AiohttpAdapter, create_oauth2_session from mastercard_oauth2_client.models import HttpRequest from tests.unit.configuration import integration_config @@ -65,6 +68,55 @@ def test_aiohttp_middleware_constructs_async_handler() -> None: assert isinstance(middleware._handler, AsyncOAuth2Handler) +def test_create_oauth2_session_preserves_options_middleware_and_tracing( + monkeypatch: Any, +) -> None: + captured: dict[str, object] = {} + expected_session = object() + + async def existing_middleware( + request: aiohttp.ClientRequest, + handler: aiohttp.ClientHandlerType, + ) -> aiohttp.ClientResponse: + return await handler(request) + + existing_trace = aiohttp.TraceConfig() + timeout = aiohttp.ClientTimeout(total=17) + + def client_session(**kwargs: object) -> object: + captured.update(kwargs) + return expected_session + + monkeypatch.setattr(aiohttp_integration.aiohttp, "ClientSession", client_session) + + session = create_oauth2_session( + integration_config(), + timeout=timeout, + middlewares=(existing_middleware,), + trace_configs=(existing_trace,), + ) + + assert session is expected_session + assert captured["timeout"] is timeout + middlewares = captured["middlewares"] + assert isinstance(middlewares, tuple) + assert isinstance(middlewares[0], OAuth2Middleware) + assert middlewares[1:] == (existing_middleware,) + assert captured["trace_configs"] == [middlewares[0].trace_config, existing_trace] + + +def test_create_oauth2_session_rejects_existing_oauth_middleware() -> None: + config = integration_config() + existing = OAuth2Middleware(config) + + with pytest.raises(OAuth2Error, match="OAuth2 middleware is already configured"): + create_oauth2_session( + config, + middlewares=(existing,), + trace_configs=(existing.trace_config,), + ) + + def test_aiohttp_adapter_maps_metadata_and_token_request_options() -> None: async def exercise() -> None: token_response = FakeResponse(status=201) @@ -106,6 +158,7 @@ async def resource_handler(resource_request: aiohttp.ClientRequest) -> aiohttp.C "data": "grant_type=client_credentials", "raise_for_status": False, "middlewares": (), + "allow_redirects": False, }, ) ] @@ -211,8 +264,9 @@ async def execute(self, resource_request: object, adapter: object) -> aiohttp.Cl async def resource_handler(resource_request: aiohttp.ClientRequest) -> aiohttp.ClientResponse: raise AssertionError(f"Unexpected resource request: {resource_request!r}") - middleware = OAuth2Middleware.__new__(OAuth2Middleware) + middleware = OAuth2Middleware(integration_config()) middleware._handler = cast(Any, FakeHandler()) + middleware._original_origin.set(("https", "api.example.com", 443)) result = await middleware(cast(aiohttp.ClientRequest, request), resource_handler) assert result is expected_response @@ -242,8 +296,9 @@ async def execute(self, resource_request: object, adapter: object) -> aiohttp.Cl async def resource_handler(resource_request: aiohttp.ClientRequest) -> aiohttp.ClientResponse: raise AssertionError(f"Unexpected resource request: {resource_request!r}") - middleware = OAuth2Middleware.__new__(OAuth2Middleware) + middleware = OAuth2Middleware(integration_config()) middleware._handler = cast(Any, FakeHandler()) + middleware._original_origin.set(("https", "api.example.com", 443)) result = await middleware(cast(aiohttp.ClientRequest, request), resource_handler) assert result is expected_response diff --git a/tests/unit/direct/httpx/test_httpx_async_transport.py b/tests/unit/direct/httpx/test_httpx_async_transport.py index 49a455f..c4bc9e2 100644 --- a/tests/unit/direct/httpx/test_httpx_async_transport.py +++ b/tests/unit/direct/httpx/test_httpx_async_transport.py @@ -1,8 +1,10 @@ import asyncio import httpx +import pytest -from mastercard_oauth2_client.integrations.httpx import AsyncOAuth2Transport +from mastercard_oauth2_client import OAuth2Error +from mastercard_oauth2_client.integrations.httpx import _ORIGINAL_ORIGIN_EXTENSION, AsyncOAuth2Transport from tests.unit.configuration import integration_config @@ -48,6 +50,32 @@ async def dispatch(request: httpx.Request) -> httpx.Response: asyncio.run(exercise()) +def test_async_httpx_transport_rejects_token_redirect() -> None: + async def exercise() -> None: + requests: list[httpx.Request] = [] + + async def dispatch(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.host == "auth.example.com": + return httpx.Response( + 307, + headers={"Location": "https://other.example.com/token"}, + request=request, + ) + return httpx.Response(401, request=request) + + transport = AsyncOAuth2Transport(integration_config(), httpx.MockTransport(dispatch)) + async with httpx.AsyncClient(transport=transport, follow_redirects=True) as client: + with pytest.raises(OAuth2Error, match="Token endpoint redirects are not supported"): + await client.get("https://api.example.com/resources") + + assert [(request.method, str(request.url)) for request in requests] == [ + ("POST", "https://auth.example.com/token"), + ] + + asyncio.run(exercise()) + + def test_async_httpx_transport_allows_concurrent_cache_misses() -> None: async def exercise() -> None: token_requests = 0 @@ -83,6 +111,75 @@ async def dispatch(request: httpx.Request) -> httpx.Response: asyncio.run(exercise()) +def test_async_httpx_transport_fails_closed_for_malformed_original_origin() -> None: + async def exercise() -> None: + resource_headers: list[httpx.Headers] = [] + + async def dispatch(request: httpx.Request) -> httpx.Response: + if request.url.host == "auth.example.com": + return httpx.Response( + 200, + json={"access_token": "token", "token_type": "DPoP", "expires_in": 900, "scope": "read"}, + request=request, + ) + resource_headers.append(request.headers) + return httpx.Response(401, request=request) + + transport = AsyncOAuth2Transport(integration_config(), httpx.MockTransport(dispatch)) + request = httpx.Request( + "GET", + "https://other.example.com/resources", + headers={"Authorization": "DPoP token", "DPoP": "proof"}, + extensions={_ORIGINAL_ORIGIN_EXTENSION: "invalid"}, + ) + + response = await transport.handle_async_request(request) + + assert response.status_code == 401 + assert "Authorization" not in resource_headers[0] + assert "DPoP" not in resource_headers[0] + + asyncio.run(exercise()) + + +def test_async_httpx_transport_keeps_oauth_off_after_cross_origin_redirect() -> None: + async def exercise() -> None: + resource_requests: list[httpx.Request] = [] + + async def dispatch(request: httpx.Request) -> httpx.Response: + if request.url.host == "auth.example.com": + return httpx.Response( + 200, + json={"access_token": "token", "token_type": "DPoP", "expires_in": 900, "scope": "read"}, + request=request, + ) + resource_requests.append(request) + if request.url.host == "api.example.com": + return httpx.Response( + 307, + headers={"Location": "https://other.example.com/next"}, + request=request, + ) + if request.url.path == "/next": + return httpx.Response(307, headers={"Location": "/final"}, request=request) + return httpx.Response(401, request=request) + + transport = AsyncOAuth2Transport(integration_config(), httpx.MockTransport(dispatch)) + async with httpx.AsyncClient(transport=transport, follow_redirects=True) as client: + response = await client.get("https://api.example.com/start") + + assert response.status_code == 401 + assert [str(request.url) for request in resource_requests] == [ + "https://api.example.com/start", + "https://other.example.com/next", + "https://other.example.com/final", + ] + assert all("Authorization" not in request.headers for request in resource_requests[1:]) + assert all("DPoP" not in request.headers for request in resource_requests[1:]) + + asyncio.run(exercise()) + + async def _body_stream(body: bytes): # type: ignore[no-untyped-def] yield body[:10] yield body[10:] diff --git a/tests/unit/direct/httpx/test_httpx_transport.py b/tests/unit/direct/httpx/test_httpx_transport.py index 885002a..0c76a38 100644 --- a/tests/unit/direct/httpx/test_httpx_transport.py +++ b/tests/unit/direct/httpx/test_httpx_transport.py @@ -1,6 +1,8 @@ import httpx +import pytest -from mastercard_oauth2_client.integrations.httpx import OAuth2Transport +from mastercard_oauth2_client import OAuth2Error +from mastercard_oauth2_client.integrations.httpx import _ORIGINAL_ORIGIN_EXTENSION, OAuth2Transport from tests.unit.configuration import integration_config @@ -41,3 +43,113 @@ def dispatch(request: httpx.Request) -> httpx.Response: assert response.history == [] assert challenge_responses[0].is_stream_consumed assert challenge_responses[0].is_closed + + +def test_httpx_transport_rejects_token_redirect() -> None: + requests: list[httpx.Request] = [] + + def dispatch(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.host == "auth.example.com": + return httpx.Response( + 307, + headers={"Location": "https://other.example.com/token"}, + request=request, + ) + return httpx.Response(401, request=request) + + transport = OAuth2Transport(integration_config(), httpx.MockTransport(dispatch)) + with ( + httpx.Client(transport=transport, follow_redirects=True) as client, + pytest.raises(OAuth2Error, match="Token endpoint redirects are not supported"), + ): + client.get("https://api.example.com/resources") + + assert [(request.method, str(request.url)) for request in requests] == [ + ("POST", "https://auth.example.com/token"), + ] + + +def test_httpx_redirect_preserves_original_origin_extension() -> None: + resource_extensions: list[object] = [] + + def dispatch(request: httpx.Request) -> httpx.Response: + if request.url.host == "auth.example.com": + return httpx.Response( + 200, + json={"access_token": "token", "token_type": "DPoP", "expires_in": 900, "scope": "read"}, + request=request, + ) + resource_extensions.append(request.extensions[_ORIGINAL_ORIGIN_EXTENSION]) + if request.url.path == "/start": + return httpx.Response(307, headers={"Location": "/next"}, request=request) + return httpx.Response(200, request=request) + + transport = OAuth2Transport(integration_config(), httpx.MockTransport(dispatch)) + with httpx.Client(transport=transport, follow_redirects=True) as client: + response = client.get("https://api.example.com/start") + + assert response.status_code == 200 + assert resource_extensions == [ + ("https", "api.example.com", 443), + ("https", "api.example.com", 443), + ] + + +def test_httpx_transport_fails_closed_for_malformed_original_origin() -> None: + resource_headers: list[httpx.Headers] = [] + + def dispatch(request: httpx.Request) -> httpx.Response: + if request.url.host == "auth.example.com": + return httpx.Response( + 200, + json={"access_token": "token", "token_type": "DPoP", "expires_in": 900, "scope": "read"}, + request=request, + ) + resource_headers.append(request.headers) + return httpx.Response(401, request=request) + + transport = OAuth2Transport(integration_config(), httpx.MockTransport(dispatch)) + request = httpx.Request( + "GET", + "https://other.example.com/resources", + headers={"Authorization": "DPoP token", "DPoP": "proof"}, + extensions={_ORIGINAL_ORIGIN_EXTENSION: "invalid"}, + ) + + response = transport.handle_request(request) + + assert response.status_code == 401 + assert "Authorization" not in resource_headers[0] + assert "DPoP" not in resource_headers[0] + + +def test_httpx_transport_keeps_oauth_off_after_cross_origin_redirect() -> None: + resource_requests: list[httpx.Request] = [] + + def dispatch(request: httpx.Request) -> httpx.Response: + if request.url.host == "auth.example.com": + return httpx.Response( + 200, + json={"access_token": "token", "token_type": "DPoP", "expires_in": 900, "scope": "read"}, + request=request, + ) + resource_requests.append(request) + if request.url.host == "api.example.com": + return httpx.Response(307, headers={"Location": "https://other.example.com/next"}, request=request) + if request.url.path == "/next": + return httpx.Response(307, headers={"Location": "/final"}, request=request) + return httpx.Response(401, request=request) + + transport = OAuth2Transport(integration_config(), httpx.MockTransport(dispatch)) + with httpx.Client(transport=transport, follow_redirects=True) as client: + response = client.get("https://api.example.com/start") + + assert response.status_code == 401 + assert [str(request.url) for request in resource_requests] == [ + "https://api.example.com/start", + "https://other.example.com/next", + "https://other.example.com/final", + ] + assert all("Authorization" not in request.headers for request in resource_requests[1:]) + assert all("DPoP" not in request.headers for request in resource_requests[1:]) diff --git a/tests/unit/direct/requests/test_requests_session.py b/tests/unit/direct/requests/test_requests_session.py index 1860256..ba3f52f 100644 --- a/tests/unit/direct/requests/test_requests_session.py +++ b/tests/unit/direct/requests/test_requests_session.py @@ -7,6 +7,7 @@ import pytest import requests +from mastercard_oauth2_client import OAuth2Error from mastercard_oauth2_client.integrations.requests import OAuth2Session from tests.unit.configuration import integration_config @@ -44,6 +45,23 @@ def close(self) -> None: self.closed = True +@pytest.mark.parametrize("status_code", [307, 308]) +def test_requests_session_rejects_token_redirect(status_code: int) -> None: + redirect = requests.Response() + redirect.status_code = status_code + redirect.headers["Location"] = "https://other.example.com/token" + redirect._content = b"" + adapter = StubRequestsAdapter([redirect]) + session = OAuth2Session(integration_config()) + session.trust_env = False + session.mount("https://", adapter) + + with pytest.raises(OAuth2Error, match="Token endpoint redirects are not supported"): + session.get("https://api.example.com/resources") + + assert [request.url for request, _ in adapter.calls] == ["https://auth.example.com/token"] + + def test_requests_session_authenticates_native_request_with_same_adapter() -> None: adapter = StubRequestsAdapter() session = OAuth2Session(integration_config()) @@ -165,3 +183,35 @@ def test_requests_session_bypasses_proxy_for_token_url_only(monkeypatch: pytest. assert token_settings["proxies"] == {} assert resource_settings["proxies"]["https"] == "http://proxy.example.com:8080" + +def test_requests_session_keeps_oauth_off_redirects_after_crossing_origin() -> None: + session = OAuth2Session(integration_config()) + session.trust_env = False + original = requests.Response() + original.request = requests.Request( + "GET", + "https://api.example.com/start", + headers={"Authorization": "DPoP token", "DPoP": "proof"}, + ).prepare() + cross_origin = requests.Request( + "GET", + "https://other.example.com/next", + headers=original.request.headers, + ).prepare() + + session.rebuild_auth(cross_origin, original) + + redirected = requests.Response() + redirected.request = cross_origin + later_hop = requests.Request( + "GET", + "https://other.example.com/final", + headers={"Authorization": "DPoP token", "DPoP": "proof"}, + ).prepare() + session.rebuild_auth(later_hop, redirected) + + assert "Authorization" not in cross_origin.headers + assert "DPoP" not in cross_origin.headers + assert "Authorization" not in later_hop.headers + assert "DPoP" not in later_hop.headers + diff --git a/tests/unit/openapi/asyncio/test_openapi_asyncio_adapter.py b/tests/unit/openapi/asyncio/test_openapi_asyncio_adapter.py index 82bcd14..03742fa 100644 --- a/tests/unit/openapi/asyncio/test_openapi_asyncio_adapter.py +++ b/tests/unit/openapi/asyncio/test_openapi_asyncio_adapter.py @@ -60,9 +60,11 @@ async def existing_middleware( ) -> aiohttp.ClientResponse: return await handler(request) + existing_trace = aiohttp.TraceConfig() original_kwargs: dict[str, Any] = { "cookie_jar": "generated-cookie-jar", "middlewares": (existing_middleware,), + "trace_configs": (existing_trace,), } api_client = ApiClient(RestClient(Configuration(original_kwargs))) @@ -76,7 +78,9 @@ async def existing_middleware( assert isinstance(middlewares, tuple) assert isinstance(middlewares[0], OAuth2Middleware) assert middlewares[1:] == (existing_middleware,) + assert session_kwargs["trace_configs"] == (middlewares[0].trace_config, existing_trace) assert original_kwargs["middlewares"] == (existing_middleware,) + assert original_kwargs["trace_configs"] == (existing_trace,) def test_add_oauth2_layer_rejects_attachment_after_pool_creation() -> None: diff --git a/tests/unit/openapi/httpx/test_openapi_httpx_adapter.py b/tests/unit/openapi/httpx/test_openapi_httpx_adapter.py index b0bdc2e..2c178fc 100644 --- a/tests/unit/openapi/httpx/test_openapi_httpx_adapter.py +++ b/tests/unit/openapi/httpx/test_openapi_httpx_adapter.py @@ -84,6 +84,49 @@ async def dispatch(request: httpx.Request) -> httpx.Response: asyncio.run(exercise()) +def test_generated_httpx_client_does_not_authenticate_cross_origin_redirect() -> None: + async def exercise() -> None: + resource_requests: list[httpx.Request] = [] + + async def dispatch(request: httpx.Request) -> httpx.Response: + if request.url.host == "auth.example.com": + return httpx.Response( + 200, + json={"access_token": "token", "token_type": "DPoP", "expires_in": 900, "scope": "read"}, + request=request, + ) + resource_requests.append(request) + if request.url.host == "api.example.com": + return httpx.Response( + 307, + headers={"Location": "https://other.example.com/final"}, + request=request, + ) + return httpx.Response(401, request=request) + + generated_config = GeneratedConfiguration(host="https://api.example.com") + api_client = GeneratedApiClient(generated_config) + add_oauth2_layer(api_client, _config(), httpx.MockTransport(dispatch)) + try: + pool_manager = api_client.rest_client.pool_manager + if pool_manager is None: + raise AssertionError("OAuth2 layer did not install generated HTTPX pool") + response = await pool_manager.get( + "https://api.example.com/start", + follow_redirects=True, + ) + finally: + await api_client.close() + + assert response.status_code == 401 + redirected_request = resource_requests[-1] + assert redirected_request.url.host == "other.example.com" + assert "Authorization" not in redirected_request.headers + assert "DPoP" not in redirected_request.headers + + asyncio.run(exercise()) + + def test_add_oauth2_layer_rejects_attachment_after_pool_creation() -> None: pool_manager = httpx.AsyncClient() api_client = ApiClient(RestClient(pool_manager=pool_manager)) diff --git a/tests/unit/openapi/urllib3/test_openapi_urllib3_adapter.py b/tests/unit/openapi/urllib3/test_openapi_urllib3_adapter.py index e88d1a5..0028792 100644 --- a/tests/unit/openapi/urllib3/test_openapi_urllib3_adapter.py +++ b/tests/unit/openapi/urllib3/test_openapi_urllib3_adapter.py @@ -7,12 +7,18 @@ from typing import Any import pytest +import urllib3 from cryptography.hazmat.primitives.asymmetric import ec, rsa from urllib3._collections import HTTPHeaderDict +from urllib3.contrib.socks import SOCKSProxyManager +from urllib3.util import Retry from mastercard_oauth2_client import KeyPair, OAuth2Config, OAuth2Error, StaticDPoPKeyProvider, StaticScopeResolver from mastercard_oauth2_client.integrations.openapi import add_oauth2_layer -from mastercard_oauth2_client.integrations.openapi.urllib3 import _generated_body +from mastercard_oauth2_client.integrations.openapi.urllib3 import ( + _disable_redirects, + _generated_body, +) from mastercard_oauth2_client.models import HttpRequest @@ -50,10 +56,11 @@ def getheader(self, name: str, default: str | None = None) -> str | None: class ScriptedRestClient: - def __init__(self, responses: list[GeneratedResponse]) -> None: + def __init__(self, responses: list[GeneratedResponse], *, pool_manager: Any = None) -> None: self.seen_responses = responses self.responses = iter(self.seen_responses) self.requests: list[tuple[str, str, dict[str, Any]]] = [] + self.pool_manager = pool_manager def request(self, method: str, url: str, **kwargs: Any) -> GeneratedResponse: self.requests.append((method, url, kwargs)) @@ -109,6 +116,61 @@ def _claims(compact_jwt: str) -> dict[str, object]: return json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))) # type: ignore[no-any-return] +def test_generated_client_preserves_retry_policy_and_disables_redirects() -> None: + class PoolManager: + def __init__(self) -> None: + self.connection_pool_kw = { + "retries": Retry( + total=7, + connect=2, + read=3, + status=4, + status_forcelist={429, 503}, + backoff_factor=0.5, + remove_headers_on_redirect={"Authorization", "Cookie"}, + ) + } + + rest_client = ScriptedRestClient([], pool_manager=PoolManager()) + + add_oauth2_layer(GeneratedApiClient(rest_client), _config()) + + retries = rest_client.pool_manager.connection_pool_kw["retries"] + assert retries.total == 7 + assert retries.connect == 2 + assert retries.read == 3 + assert retries.status == 4 + assert retries.status_forcelist == {429, 503} + assert retries.backoff_factor == 0.5 + assert retries.redirect == 0 + assert retries.raise_on_redirect is False + assert retries.remove_headers_on_redirect == frozenset({"authorization", "cookie"}) + + +@pytest.mark.parametrize( + "pool_manager", + [ + pytest.param(urllib3.PoolManager(retries=Retry(total=7, backoff_factor=0.5)), id="pool"), + pytest.param( + urllib3.ProxyManager("http://localhost:8080", retries=Retry(total=7, backoff_factor=0.5)), + id="proxy", + ), + pytest.param( + SOCKSProxyManager("socks5://localhost:1080", retries=Retry(total=7, backoff_factor=0.5)), + id="socks-proxy", + ), + ], +) +def test_redirect_policy_supports_urllib3_manager_types(pool_manager: Any) -> None: + _disable_redirects(pool_manager) + + retries = pool_manager.connection_pool_kw["retries"] + assert retries.total == 7 + assert retries.backoff_factor == 0.5 + assert retries.redirect == 0 + assert retries.raise_on_redirect is False + + def test_generated_client_call_is_authenticated_without_generated_code_changes() -> None: rest_client = ScriptedRestClient( [ diff --git a/tests/unit/test_async_handler.py b/tests/unit/test_async_handler.py index fc2be8b..4c206a0 100644 --- a/tests/unit/test_async_handler.py +++ b/tests/unit/test_async_handler.py @@ -190,6 +190,22 @@ def parse_after_close( asyncio.run(exercise()) +@pytest.mark.parametrize("status", [300, 307, 308, 399]) +def test_async_handler_rejects_token_endpoint_redirect(status: int) -> None: + async def exercise() -> None: + redirect = NativeResponse(status=status, headers={"Location": "https://other.example.com/token"}) + adapter = AsyncScriptedAdapter([redirect]) + + with pytest.raises(OAuth2Error, match="Token endpoint redirects are not supported"): + await AsyncOAuth2Handler[NativeRequest, NativeResponse](_config()).execute(_request(), adapter) + + assert redirect.closed + assert redirect.body_reads == 0 + assert len(adapter.requests) == 1 + + asyncio.run(exercise()) + + @pytest.mark.parametrize("status", [400, 401]) def test_async_handler_retries_token_nonce_once(status: int) -> None: async def exercise() -> None: diff --git a/tests/unit/test_handler.py b/tests/unit/test_handler.py index 0e27504..2c3310c 100644 --- a/tests/unit/test_handler.py +++ b/tests/unit/test_handler.py @@ -239,6 +239,19 @@ def parse_after_close( OAuth2Handler[NativeRequest, NativeResponse](config).execute(_resource_request(), transport) +@pytest.mark.parametrize("status", [300, 307, 308, 399]) +def test_handler_rejects_token_endpoint_redirect(status: int) -> None: + redirect = NativeResponse(status=status, headers={"Location": "https://other.example.com/token"}) + transport = ScriptedAdapter([redirect]) + + with pytest.raises(OAuth2Error, match="Token endpoint redirects are not supported"): + OAuth2Handler[NativeRequest, NativeResponse](_config()).execute(_resource_request(), transport) + + assert redirect.closed + assert redirect.body_reads == 0 + assert len(transport.requests) == 1 + + @pytest.mark.parametrize("challenge_status", [400, 401]) def test_handler_retries_token_request_once_with_server_nonce(challenge_status: int) -> None: config = _config() diff --git a/tests/unit/test_orchestration.py b/tests/unit/test_orchestration.py index 441b166..e0e63b5 100644 --- a/tests/unit/test_orchestration.py +++ b/tests/unit/test_orchestration.py @@ -1,9 +1,30 @@ import pytest from multidict import CIMultiDict +from mastercard_oauth2_client._internal.http import http_origin, strip_oauth_headers from mastercard_oauth2_client._internal.orchestration import has_nonce_challenge_body, has_nonce_challenge_header +def test_http_origin_normalizes_case_and_default_port() -> None: + assert http_origin("HTTPS://API.EXAMPLE.COM/resource") == http_origin("https://api.example.com:443/other") + assert http_origin("https://api.example.com:8443/resource") != http_origin("https://api.example.com/resource") + + +def test_strip_oauth_headers_removes_mixed_case_duplicates() -> None: + headers = CIMultiDict( + [ + ("authorization", "first"), + ("Authorization", "second"), + ("DPoP", "proof"), + ("X-Correlation-ID", "correlation"), + ] + ) + + strip_oauth_headers(headers) + + assert list(headers.items()) == [("X-Correlation-ID", "correlation")] + + @pytest.mark.parametrize("body", [None, b"", "not-json", "[]"]) def test_nonce_challenge_body_ignores_absent_or_invalid_error_objects(body: bytes | str | None) -> None: assert not has_nonce_challenge_body(401, body) From 797597486b6a6e40bc80af7c600484633615cc6c Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Fri, 9 Oct 2026 23:27:21 +0530 Subject: [PATCH 29/36] Fix redirects for older urllib3 versions --- scripts/check_wheel.py | 2 +- .../integrations/openapi/urllib3.py | 15 +++--- .../urllib3/test_openapi_urllib3_adapter.py | 49 ++++++++++++------- 3 files changed, 40 insertions(+), 26 deletions(-) diff --git a/scripts/check_wheel.py b/scripts/check_wheel.py index 2d9324f..0361cfe 100644 --- a/scripts/check_wheel.py +++ b/scripts/check_wheel.py @@ -17,7 +17,7 @@ def main() -> None: raise SystemExit("No wheel found in dist. Run 'python -m build' first.") _check_contents(wheel) - _check_install(wheel, "core", absent=("httpx", "aiohttp", "requests")) + _check_install(wheel, "core", absent=("httpx", "aiohttp", "requests", "urllib3")) _check_install(wheel, "httpx", imports=("mastercard_oauth2_client.integrations.httpx",)) _check_install(wheel, "aiohttp", imports=("mastercard_oauth2_client.integrations.aiohttp",)) _check_install(wheel, "requests", imports=("mastercard_oauth2_client.integrations.requests",)) diff --git a/src/mastercard_oauth2_client/integrations/openapi/urllib3.py b/src/mastercard_oauth2_client/integrations/openapi/urllib3.py index 390bd74..a1f8f58 100644 --- a/src/mastercard_oauth2_client/integrations/openapi/urllib3.py +++ b/src/mastercard_oauth2_client/integrations/openapi/urllib3.py @@ -7,8 +7,6 @@ from typing import Any, Protocol, TypeAlias, runtime_checkable from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit -from urllib3.util import Retry - from ...config import OAuth2Config from ...exceptions import OAuth2Error from ...handlers import OAuth2Handler @@ -43,7 +41,7 @@ def request(self, method: str, url: str, **kwargs: Any) -> _GeneratedResponse: . @runtime_checkable class _PoolManager(Protocol): - connection_pool_kw: dict[str, Any] + def urlopen(self, method: str, url: str, redirect: bool = True, **kwargs: Any) -> Any: ... class _ApiClient(Protocol): @@ -171,10 +169,13 @@ def authenticated_request(method: str, url: str, **kwargs: Any) -> _GeneratedRes def _disable_redirects(pool_manager: _PoolManager) -> None: - retries = pool_manager.connection_pool_kw.get("retries", Retry.DEFAULT) - if not isinstance(retries, Retry): - retries = Retry.from_int(retries) - pool_manager.connection_pool_kw["retries"] = retries.new(redirect=False) + original_urlopen = pool_manager.urlopen + + @wraps(original_urlopen) + def urlopen_without_redirects(method: str, url: str, redirect: bool = True, **kwargs: Any) -> Any: + return original_urlopen(method, url, redirect=False, **kwargs) + + pool_manager.urlopen = urlopen_without_redirects # type: ignore[method-assign] def _url_with_query(url: str, query_params: object) -> str: diff --git a/tests/unit/openapi/urllib3/test_openapi_urllib3_adapter.py b/tests/unit/openapi/urllib3/test_openapi_urllib3_adapter.py index 0028792..6ecb1ba 100644 --- a/tests/unit/openapi/urllib3/test_openapi_urllib3_adapter.py +++ b/tests/unit/openapi/urllib3/test_openapi_urllib3_adapter.py @@ -119,32 +119,39 @@ def _claims(compact_jwt: str) -> dict[str, object]: def test_generated_client_preserves_retry_policy_and_disables_redirects() -> None: class PoolManager: def __init__(self) -> None: - self.connection_pool_kw = { - "retries": Retry( - total=7, - connect=2, - read=3, - status=4, - status_forcelist={429, 503}, - backoff_factor=0.5, - remove_headers_on_redirect={"Authorization", "Cookie"}, - ) - } + self.retries = Retry( + total=7, + connect=2, + read=3, + status=4, + status_forcelist={429, 503}, + backoff_factor=0.5, + remove_headers_on_redirect={"Authorization", "Cookie"}, + ) + self.connection_pool_kw = {"retries": self.retries} + self.calls: list[dict[str, Any]] = [] + + def urlopen(self, method: str, url: str, redirect: bool = True, **kwargs: Any) -> object: + self.calls.append({"method": method, "url": url, "redirect": redirect, **kwargs}) + return object() rest_client = ScriptedRestClient([], pool_manager=PoolManager()) add_oauth2_layer(GeneratedApiClient(rest_client), _config()) + rest_client.pool_manager.urlopen("GET", "https://api.example.com/start", redirect=True) retries = rest_client.pool_manager.connection_pool_kw["retries"] + assert retries is rest_client.pool_manager.retries assert retries.total == 7 assert retries.connect == 2 assert retries.read == 3 assert retries.status == 4 assert retries.status_forcelist == {429, 503} assert retries.backoff_factor == 0.5 - assert retries.redirect == 0 - assert retries.raise_on_redirect is False assert retries.remove_headers_on_redirect == frozenset({"authorization", "cookie"}) + assert rest_client.pool_manager.calls == [ + {"method": "GET", "url": "https://api.example.com/start", "redirect": False} + ] @pytest.mark.parametrize( @@ -162,13 +169,19 @@ def __init__(self) -> None: ], ) def test_redirect_policy_supports_urllib3_manager_types(pool_manager: Any) -> None: - _disable_redirects(pool_manager) + calls: list[dict[str, Any]] = [] + + def urlopen(method: str, url: str, redirect: bool = True, **kwargs: Any) -> object: + calls.append({"method": method, "url": url, "redirect": redirect, **kwargs}) + return object() retries = pool_manager.connection_pool_kw["retries"] - assert retries.total == 7 - assert retries.backoff_factor == 0.5 - assert retries.redirect == 0 - assert retries.raise_on_redirect is False + pool_manager.urlopen = urlopen + _disable_redirects(pool_manager) + pool_manager.urlopen("GET", "https://api.example.com/start", redirect=True) + + assert pool_manager.connection_pool_kw["retries"] is retries + assert calls == [{"method": "GET", "url": "https://api.example.com/start", "redirect": False}] def test_generated_client_call_is_authenticated_without_generated_code_changes() -> None: From bce267cbedcef77fb4f0fbf5afc42e53c0b5449c Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Sun, 11 Oct 2026 22:33:13 +0530 Subject: [PATCH 30/36] Review comments: Isolate generated asyncio client configuration --- .../integrations/openapi/asyncio.py | 10 ++++---- .../asyncio/test_openapi_asyncio_client.py | 23 +++++++++++++++++++ .../asyncio/test_openapi_asyncio_adapter.py | 18 +++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/mastercard_oauth2_client/integrations/openapi/asyncio.py b/src/mastercard_oauth2_client/integrations/openapi/asyncio.py index b646e95..2874e32 100644 --- a/src/mastercard_oauth2_client/integrations/openapi/asyncio.py +++ b/src/mastercard_oauth2_client/integrations/openapi/asyncio.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Sequence +from copy import copy from typing import Any, Protocol, cast import aiohttp @@ -15,8 +16,7 @@ class _AsyncioConfiguration(Protocol): class _AsyncioRestClient(Protocol): - @property - def configuration(self) -> _AsyncioConfiguration: ... + configuration: _AsyncioConfiguration @property def pool_manager(self) -> aiohttp.ClientSession | None: ... @@ -38,11 +38,13 @@ def add_oauth2_layer(api_client: _ApiClient, config: OAuth2Config) -> None: existing_middlewares = cast(Sequence[aiohttp.ClientMiddlewareType], session_kwargs.get("middlewares") or ()) if any(isinstance(middleware, OAuth2Middleware) for middleware in existing_middlewares): raise OAuth2Error("OAuth2 is already attached to the generated asyncio client") + configuration = copy(rest_client.configuration) + rest_client.configuration = configuration middleware = OAuth2Middleware(config) configured_trace_configs = session_kwargs.get("trace_configs") if configured_trace_configs is None: - configured_trace_configs = getattr(rest_client.configuration, "trace_configs", None) + configured_trace_configs = getattr(configuration, "trace_configs", None) existing_trace_configs = cast(Sequence[aiohttp.TraceConfig], configured_trace_configs or ()) session_kwargs["middlewares"] = (middleware, *existing_middlewares) session_kwargs["trace_configs"] = (middleware.trace_config, *existing_trace_configs) - rest_client.configuration.client_session_kwargs = session_kwargs + configuration.client_session_kwargs = session_kwargs diff --git a/tests/integration/openapi/asyncio/test_openapi_asyncio_client.py b/tests/integration/openapi/asyncio/test_openapi_asyncio_client.py index ab8f934..002969b 100644 --- a/tests/integration/openapi/asyncio/test_openapi_asyncio_client.py +++ b/tests/integration/openapi/asyncio/test_openapi_asyncio_client.py @@ -328,6 +328,29 @@ async def record_trace( assert timeout_preserved +def test_openapi_asyncio_does_not_modify_shared_default_configuration( + oauth_environment: OAuthTestEnvironment, +) -> None: + shared_configuration = GeneratedConfiguration.get_default() + original_session_kwargs = shared_configuration.client_session_kwargs + authenticated_client = GeneratedApiClient() + other_client = GeneratedApiClient() + + try: + assert authenticated_client.rest_client.configuration is shared_configuration + assert other_client.rest_client.configuration is shared_configuration + + add_oauth2_layer(authenticated_client, oauth_environment.config("ec")) + + assert authenticated_client.rest_client.configuration is not shared_configuration + assert authenticated_client.rest_client.configuration.client_session_kwargs is not None + assert shared_configuration.client_session_kwargs is original_session_kwargs + assert other_client.rest_client.configuration is shared_configuration + assert other_client.rest_client.configuration.client_session_kwargs is original_session_kwargs + finally: + shared_configuration.client_session_kwargs = original_session_kwargs + + def test_openapi_asyncio_retry_client_reuses_oauth2_session_and_cached_token( oauth_environment: OAuthTestEnvironment, ) -> None: diff --git a/tests/unit/openapi/asyncio/test_openapi_asyncio_adapter.py b/tests/unit/openapi/asyncio/test_openapi_asyncio_adapter.py index 03742fa..111ac24 100644 --- a/tests/unit/openapi/asyncio/test_openapi_asyncio_adapter.py +++ b/tests/unit/openapi/asyncio/test_openapi_asyncio_adapter.py @@ -83,6 +83,21 @@ async def existing_middleware( assert original_kwargs["trace_configs"] == (existing_trace,) +def test_add_oauth2_layer_does_not_modify_shared_configuration() -> None: + shared_configuration = Configuration() + authenticated_client = ApiClient(RestClient(shared_configuration)) + other_client = ApiClient(RestClient(shared_configuration)) + + add_oauth2_layer(authenticated_client, _config()) + + authenticated_configuration = authenticated_client.rest_client.configuration + assert authenticated_configuration is not shared_configuration + assert authenticated_configuration.client_session_kwargs is not None + assert shared_configuration.client_session_kwargs is None + assert other_client.rest_client.configuration is shared_configuration + assert other_client.rest_client.configuration.client_session_kwargs is None + + def test_add_oauth2_layer_rejects_attachment_after_pool_creation() -> None: async def exercise() -> None: pool_manager = aiohttp.ClientSession() @@ -100,6 +115,9 @@ def test_add_oauth2_layer_rejects_duplicate_attachment_before_pool_creation() -> api_client = ApiClient() config = _config() add_oauth2_layer(api_client, config) + configured = api_client.rest_client.configuration with pytest.raises(OAuth2Error, match="already attached"): add_oauth2_layer(api_client, config) + + assert api_client.rest_client.configuration is configured From e326c3eaad031bc1059a5397f4eeca593d88aa09 Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Sun, 11 Oct 2026 22:37:42 +0530 Subject: [PATCH 31/36] Prevent nonce mix-ups in concurrent retries --- .../_internal/orchestration.py | 6 +-- .../handlers/async_.py | 22 ++++++---- src/mastercard_oauth2_client/handlers/sync.py | 22 ++++++---- tests/unit/test_async_handler.py | 41 +++++++++++++++++++ tests/unit/test_handler.py | 37 ++++++++++++++++- 5 files changed, 110 insertions(+), 18 deletions(-) diff --git a/src/mastercard_oauth2_client/_internal/orchestration.py b/src/mastercard_oauth2_client/_internal/orchestration.py index 7079292..8495c6f 100644 --- a/src/mastercard_oauth2_client/_internal/orchestration.py +++ b/src/mastercard_oauth2_client/_internal/orchestration.py @@ -31,13 +31,13 @@ def get(self) -> str | None: with self._lock: return self._nonce - def update(self, headers: Mapping[str, str]) -> bool: + def update(self, headers: Mapping[str, str]) -> str | None: nonce = get_header(headers, _DPOP_NONCE_HEADER) if nonce is None or not nonce.strip(): - return False + return None with self._lock: self._nonce = nonce - return True + return nonce def require_resource_url(url: str) -> None: diff --git a/src/mastercard_oauth2_client/handlers/async_.py b/src/mastercard_oauth2_client/handlers/async_.py index 8f57c57..6620b62 100644 --- a/src/mastercard_oauth2_client/handlers/async_.py +++ b/src/mastercard_oauth2_client/handlers/async_.py @@ -126,11 +126,16 @@ async def _send_token_request_with_retry( request = build_token_request(self._config, scopes, dpop_key.key_id, self._nonces.get()) response = await adapter.send_token_request(resource_request, request) - self._update_nonce(adapter.response_headers(response)) + response_nonce = self._update_nonce(adapter.response_headers(response)) if await self._is_nonce_challenge(adapter, response): await adapter.close_response(response) _LOGGER.info("DPoP nonce-directed retry for token request") - retry_request = build_token_request(self._config, scopes, dpop_key.key_id, self._nonces.get()) + retry_request = build_token_request( + self._config, + scopes, + dpop_key.key_id, + response_nonce or self._nonces.get(), + ) response = await adapter.send_token_request(resource_request, retry_request) self._update_nonce(adapter.response_headers(response)) return response @@ -146,11 +151,11 @@ async def _send_resource_request( headers = self._resource_headers(request, adapter, access_token, dpop_key) response = await adapter.send_resource_request(request, headers) - self._update_nonce(adapter.response_headers(response)) + response_nonce = self._update_nonce(adapter.response_headers(response)) if await self._is_nonce_challenge(adapter, response): await adapter.close_response(response) _LOGGER.info("DPoP nonce-directed retry for resource request") - retry_headers = self._resource_headers(request, adapter, access_token, dpop_key) + retry_headers = self._resource_headers(request, adapter, access_token, dpop_key, response_nonce) response = await adapter.send_resource_request(request, retry_headers) self._update_nonce(adapter.response_headers(response)) return response @@ -161,6 +166,7 @@ def _resource_headers( adapter: AsyncHttpAdapter[RequestT, ResponseT], access_token: AccessToken, dpop_key: DPoPKey, + nonce: str | None = None, ) -> dict[str, str]: """Build OAuth2 headers without copying or serializing the native request.""" @@ -171,14 +177,16 @@ def _resource_headers( adapter.request_url(request), access_token, dpop_key, - self._nonces.get(), + nonce or self._nonces.get(), ) - def _update_nonce(self, headers: Mapping[str, str]) -> None: + def _update_nonce(self, headers: Mapping[str, str]) -> str | None: """Store a non-empty DPoP nonce returned in response headers.""" - if self._nonces.update(headers): + nonce = self._nonces.update(headers) + if nonce is not None: _LOGGER.debug("DPoP nonce received") + return nonce @staticmethod async def _is_nonce_challenge( diff --git a/src/mastercard_oauth2_client/handlers/sync.py b/src/mastercard_oauth2_client/handlers/sync.py index 38749ec..68b7cd8 100644 --- a/src/mastercard_oauth2_client/handlers/sync.py +++ b/src/mastercard_oauth2_client/handlers/sync.py @@ -113,11 +113,16 @@ def _send_token_request_with_retry( request = build_token_request(self._config, scopes, dpop_key.key_id, self._nonces.get()) response = adapter.send_token_request(resource_request, request) - self._update_nonce(adapter.response_headers(response)) + response_nonce = self._update_nonce(adapter.response_headers(response)) if self._is_nonce_challenge(adapter, response): adapter.close_response(response) _LOGGER.info("DPoP nonce-directed retry for token request") - retry_request = build_token_request(self._config, scopes, dpop_key.key_id, self._nonces.get()) + retry_request = build_token_request( + self._config, + scopes, + dpop_key.key_id, + response_nonce or self._nonces.get(), + ) response = adapter.send_token_request(resource_request, retry_request) self._update_nonce(adapter.response_headers(response)) return response @@ -133,11 +138,11 @@ def _send_resource_request( headers = self._resource_headers(request, adapter, access_token, dpop_key) response = adapter.send_resource_request(request, headers) - self._update_nonce(adapter.response_headers(response)) + response_nonce = self._update_nonce(adapter.response_headers(response)) if self._is_nonce_challenge(adapter, response): adapter.close_response(response) _LOGGER.info("DPoP nonce-directed retry for resource request") - retry_headers = self._resource_headers(request, adapter, access_token, dpop_key) + retry_headers = self._resource_headers(request, adapter, access_token, dpop_key, response_nonce) response = adapter.send_resource_request(request, retry_headers) self._update_nonce(adapter.response_headers(response)) return response @@ -161,6 +166,7 @@ def _resource_headers( adapter: SyncHttpAdapter[RequestT, ResponseT], access_token: AccessToken, dpop_key: DPoPKey, + nonce: str | None = None, ) -> dict[str, str]: """Build OAuth2 headers without copying or serializing the native request.""" @@ -171,14 +177,16 @@ def _resource_headers( adapter.request_url(request), access_token, dpop_key, - self._nonces.get(), + nonce or self._nonces.get(), ) - def _update_nonce(self, headers: Mapping[str, str]) -> None: + def _update_nonce(self, headers: Mapping[str, str]) -> str | None: """Store a non-empty DPoP nonce returned in response headers.""" - if self._nonces.update(headers): + nonce = self._nonces.update(headers) + if nonce is not None: _LOGGER.debug("DPoP nonce received") + return nonce @staticmethod def _is_nonce_challenge(adapter: SyncHttpAdapter[RequestT, ResponseT], response: ResponseT) -> bool: diff --git a/tests/unit/test_async_handler.py b/tests/unit/test_async_handler.py index 4c206a0..2d8e0de 100644 --- a/tests/unit/test_async_handler.py +++ b/tests/unit/test_async_handler.py @@ -241,6 +241,47 @@ async def exercise() -> None: asyncio.run(exercise()) +@pytest.mark.parametrize("challenge_target", ["token", "resource"]) +def test_async_handler_isolates_concurrent_nonce_retries(challenge_target: str) -> None: + async def exercise() -> None: + both_challenges_received = asyncio.Event() + closed_challenges = 0 + + class CoordinatedAdapter(AsyncScriptedAdapter): + async def close_response(self, response: NativeResponse) -> None: + nonlocal closed_challenges + response.closed = True + if response.status in {400, 401}: + closed_challenges += 1 + if closed_challenges == 2: + both_challenges_received.set() + await asyncio.wait_for(both_challenges_received.wait(), timeout=5) + + def adapter(nonce: str) -> CoordinatedAdapter: + challenge = _challenge(nonce, status=400 if challenge_target == "token" else 401) + responses = ( + [challenge, _token_response(), NativeResponse(status=200, headers={})] + if challenge_target == "token" + else [_token_response(), challenge, NativeResponse(status=200, headers={})] + ) + return CoordinatedAdapter(responses) + + first = adapter("nonce-a") + second = adapter("nonce-b") + handler = AsyncOAuth2Handler[NativeRequest, NativeResponse](_config()) + + await asyncio.gather( + handler.execute(_request("https://api.example.com/a"), first), + handler.execute(_request("https://api.example.com/b"), second), + ) + + retry_index = 1 if challenge_target == "token" else 2 + assert _claims(first.requests[retry_index].headers["DPoP"])["nonce"] == "nonce-a" + assert _claims(second.requests[retry_index].headers["DPoP"])["nonce"] == "nonce-b" + + asyncio.run(exercise()) + + def test_async_handler_retries_body_only_resource_nonce() -> None: async def exercise() -> None: first = _challenge("resource-nonce", header=False) diff --git a/tests/unit/test_handler.py b/tests/unit/test_handler.py index 2c3310c..c37df4c 100644 --- a/tests/unit/test_handler.py +++ b/tests/unit/test_handler.py @@ -2,9 +2,10 @@ import json from collections.abc import Iterable, Mapping from collections.abc import Set as AbstractSet +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, replace from datetime import UTC, datetime -from threading import Lock +from threading import Barrier, Lock from urllib.parse import parse_qs import pytest @@ -299,6 +300,40 @@ def test_handler_retries_resource_request_once_with_server_nonce(challenge_statu assert first_challenge.closed +@pytest.mark.parametrize("challenge_target", ["token", "resource"]) +def test_handler_isolates_concurrent_nonce_retries(challenge_target: str) -> None: + challenge_barrier = Barrier(2, timeout=5) + + class CoordinatedAdapter(ScriptedAdapter): + def close_response(self, response: NativeResponse) -> None: + super().close_response(response) + if response.status in {400, 401}: + challenge_barrier.wait() + + def adapter(nonce: str) -> CoordinatedAdapter: + challenge = _nonce_challenge(nonce, status=400 if challenge_target == "token" else 401) + responses = ( + [challenge, _token_response(), NativeResponse(status=200, headers={})] + if challenge_target == "token" + else [_token_response(), challenge, NativeResponse(status=200, headers={})] + ) + return CoordinatedAdapter(responses) + + first = adapter("nonce-a") + second = adapter("nonce-b") + handler = OAuth2Handler[NativeRequest, NativeResponse](_config()) + + with ThreadPoolExecutor(max_workers=2) as executor: + first_result = executor.submit(handler.execute, _resource_request(), first) + second_result = executor.submit(handler.execute, _resource_request(), second) + first_result.result(timeout=5) + second_result.result(timeout=5) + + retry_index = 1 if challenge_target == "token" else 2 + assert _decode_claims(first.requests[retry_index].headers["DPoP"])["nonce"] == "nonce-a" + assert _decode_claims(second.requests[retry_index].headers["DPoP"])["nonce"] == "nonce-b" + + def test_handler_retries_body_only_resource_nonce() -> None: config = _config() first_challenge = _nonce_challenge("resource-nonce", header_error=False) From bfb894fe01cc11c43a844bce19a38bb69eeb8593 Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Sun, 11 Oct 2026 22:57:28 +0530 Subject: [PATCH 32/36] Make concurrent nonce tests deterministic --- tests/unit/test_async_handler.py | 18 +++++++++++++++--- tests/unit/test_handler.py | 18 +++++++++++++++--- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/tests/unit/test_async_handler.py b/tests/unit/test_async_handler.py index 2d8e0de..1a083c0 100644 --- a/tests/unit/test_async_handler.py +++ b/tests/unit/test_async_handler.py @@ -262,20 +262,32 @@ def adapter(nonce: str) -> CoordinatedAdapter: responses = ( [challenge, _token_response(), NativeResponse(status=200, headers={})] if challenge_target == "token" - else [_token_response(), challenge, NativeResponse(status=200, headers={})] + else [challenge, NativeResponse(status=200, headers={})] ) return CoordinatedAdapter(responses) first = adapter("nonce-a") second = adapter("nonce-b") - handler = AsyncOAuth2Handler[NativeRequest, NativeResponse](_config()) + config = _config() + if challenge_target == "resource": + dpop_key = config.dpop_key_provider.get_current_key() + config.token_store.put( + AccessToken( + client_id=config.client_id, + token_value="cached-token", + scopes=frozenset({"payments:read"}), + expires_at=datetime(2100, 1, 1, tzinfo=UTC), + jkt=jwk_thumbprint(dpop_key.key_pair.public_key), + ) + ) + handler = AsyncOAuth2Handler[NativeRequest, NativeResponse](config) await asyncio.gather( handler.execute(_request("https://api.example.com/a"), first), handler.execute(_request("https://api.example.com/b"), second), ) - retry_index = 1 if challenge_target == "token" else 2 + retry_index = 1 assert _claims(first.requests[retry_index].headers["DPoP"])["nonce"] == "nonce-a" assert _claims(second.requests[retry_index].headers["DPoP"])["nonce"] == "nonce-b" diff --git a/tests/unit/test_handler.py b/tests/unit/test_handler.py index c37df4c..3691f4f 100644 --- a/tests/unit/test_handler.py +++ b/tests/unit/test_handler.py @@ -315,13 +315,25 @@ def adapter(nonce: str) -> CoordinatedAdapter: responses = ( [challenge, _token_response(), NativeResponse(status=200, headers={})] if challenge_target == "token" - else [_token_response(), challenge, NativeResponse(status=200, headers={})] + else [challenge, NativeResponse(status=200, headers={})] ) return CoordinatedAdapter(responses) first = adapter("nonce-a") second = adapter("nonce-b") - handler = OAuth2Handler[NativeRequest, NativeResponse](_config()) + config = _config() + if challenge_target == "resource": + dpop_key = config.dpop_key_provider.get_current_key() + config.token_store.put( + AccessToken( + client_id=config.client_id, + token_value="cached-token", + scopes=frozenset({"payments:read"}), + expires_at=datetime(2100, 1, 1, tzinfo=UTC), + jkt=jwk_thumbprint(dpop_key.key_pair.public_key), + ) + ) + handler = OAuth2Handler[NativeRequest, NativeResponse](config) with ThreadPoolExecutor(max_workers=2) as executor: first_result = executor.submit(handler.execute, _resource_request(), first) @@ -329,7 +341,7 @@ def adapter(nonce: str) -> CoordinatedAdapter: first_result.result(timeout=5) second_result.result(timeout=5) - retry_index = 1 if challenge_target == "token" else 2 + retry_index = 1 assert _decode_claims(first.requests[retry_index].headers["DPoP"])["nonce"] == "nonce-a" assert _decode_claims(second.requests[retry_index].headers["DPoP"])["nonce"] == "nonce-b" From 937274b348e7f15635a133071fcc3d871df41c5b Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Sun, 11 Oct 2026 23:45:58 +0530 Subject: [PATCH 33/36] Review comments: Use configured User-Agent for resource requests --- src/mastercard_oauth2_client/_internal/orchestration.py | 7 ++++--- tests/integration/direct/aiohttp/test_aiohttp_client.py | 6 ++++-- tests/integration/direct/httpx/test_httpx_async_client.py | 6 ++++-- tests/integration/direct/httpx/test_httpx_sync_client.py | 5 +++-- tests/integration/direct/requests/test_requests_client.py | 5 +++-- .../openapi/asyncio/test_openapi_asyncio_client.py | 6 ++++-- .../integration/openapi/httpx/test_openapi_httpx_client.py | 6 ++++-- .../openapi/urllib3/test_openapi_urllib3_client.py | 5 +++-- tests/unit/test_async_handler.py | 7 ++++--- tests/unit/test_handler.py | 2 +- 10 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/mastercard_oauth2_client/_internal/orchestration.py b/src/mastercard_oauth2_client/_internal/orchestration.py index 8495c6f..1b910d9 100644 --- a/src/mastercard_oauth2_client/_internal/orchestration.py +++ b/src/mastercard_oauth2_client/_internal/orchestration.py @@ -17,7 +17,9 @@ _USER_AGENT_HEADER = "User-Agent" _WWW_AUTHENTICATE_HEADER = "WWW-Authenticate" _USE_DPOP_NONCE = "use_dpop_nonce" -_OAUTH_CONTROLLED_HEADERS = frozenset({_AUTHORIZATION_HEADER.casefold(), _DPOP_HEADER.casefold()}) +_OAUTH_CONTROLLED_HEADERS = frozenset( + {_AUTHORIZATION_HEADER.casefold(), _DPOP_HEADER.casefold(), _USER_AGENT_HEADER.casefold()} +) class NonceStore: @@ -61,8 +63,7 @@ def build_resource_headers( headers = { name: value for name, value in existing_headers.items() if name.casefold() not in _OAUTH_CONTROLLED_HEADERS } - if get_header(headers, _USER_AGENT_HEADER) is None: - headers[_USER_AGENT_HEADER] = config.user_agent + headers[_USER_AGENT_HEADER] = config.user_agent headers[_AUTHORIZATION_HEADER] = f"DPoP {access_token.token_value}" headers[_DPOP_HEADER] = create_resource_dpop_proof( config, diff --git a/tests/integration/direct/aiohttp/test_aiohttp_client.py b/tests/integration/direct/aiohttp/test_aiohttp_client.py index 5a4ff2e..c1afdef 100644 --- a/tests/integration/direct/aiohttp/test_aiohttp_client.py +++ b/tests/integration/direct/aiohttp/test_aiohttp_client.py @@ -156,8 +156,10 @@ async def exercise() -> int: def test_aiohttp_client_preserves_query_json_and_caller_headers( oauth_environment: OAuthTestEnvironment, ) -> None: + config = oauth_environment.config("ec") + async def exercise() -> tuple[int, dict[str, object]]: - async with _client(oauth_environment) as client: + async with _client(oauth_environment, config=config) as client: response = await client.post( f"{oauth_environment.resource.base_url}/api/resources", params={"source": "aiohttp"}, @@ -178,7 +180,7 @@ async def exercise() -> tuple[int, dict[str, object]]: assert response_body == {"id": "1", "name": "aiohttp-resource"} assert recorded.path == "/api/resources?source=aiohttp" assert recorded.body == b'{"name": "aiohttp-resource"}' - assert recorded.header("user-agent") == "caller-agent" + assert recorded.header("user-agent") == config.user_agent assert recorded.header("authorization") == "DPoP integration-access-token" assert recorded.header("dpop") != "stale-proof" diff --git a/tests/integration/direct/httpx/test_httpx_async_client.py b/tests/integration/direct/httpx/test_httpx_async_client.py index 39e0fc9..306e317 100644 --- a/tests/integration/direct/httpx/test_httpx_async_client.py +++ b/tests/integration/direct/httpx/test_httpx_async_client.py @@ -120,8 +120,10 @@ async def exercise() -> int: def test_async_httpx_client_preserves_query_json_and_caller_headers( oauth_environment: OAuthTestEnvironment, ) -> None: + config = oauth_environment.config("ec") + async def exercise() -> tuple[httpx.Response, RecordedRequest]: - async with _client(oauth_environment) as client: + async with _client(oauth_environment, config=config) as client: create_response = await client.post( f"{oauth_environment.resource.base_url}/api/resources", params={"source": "httpx-async"}, @@ -144,7 +146,7 @@ async def exercise() -> tuple[httpx.Response, RecordedRequest]: create_response, recorded = asyncio.run(exercise()) assert recorded.path == "/api/resources?source=httpx-async" assert recorded.body == b'{"name":"httpx-async-resource"}' - assert recorded.header("user-agent") == "caller-agent" + assert recorded.header("user-agent") == config.user_agent assert recorded.header("authorization") == "DPoP integration-access-token" assert recorded.header("dpop") != "stale-proof" assert create_response.request.url.query == b"source=httpx-async" diff --git a/tests/integration/direct/httpx/test_httpx_sync_client.py b/tests/integration/direct/httpx/test_httpx_sync_client.py index 5f01bec..abb373c 100644 --- a/tests/integration/direct/httpx/test_httpx_sync_client.py +++ b/tests/integration/direct/httpx/test_httpx_sync_client.py @@ -62,7 +62,8 @@ def test_httpx_lifecycle_attempts_delete_after_get_failure(oauth_environment: OA def test_httpx_client_preserves_query_json_and_caller_headers(oauth_environment: OAuthTestEnvironment) -> None: - with _client(oauth_environment) as client: + config = oauth_environment.config("ec") + with _client(oauth_environment, config=config) as client: create_response = client.post( f"{oauth_environment.resource.base_url}/api/resources", params={"source": "httpx"}, @@ -80,7 +81,7 @@ def test_httpx_client_preserves_query_json_and_caller_headers(oauth_environment: recorded = oauth_environment.resource.state.requests[-1] assert recorded.path == "/api/resources?source=httpx" assert recorded.body == b'{"name":"httpx-resource"}' - assert recorded.header("user-agent") == "caller-agent" + assert recorded.header("user-agent") == config.user_agent assert recorded.header("authorization") == "DPoP integration-access-token" assert recorded.header("dpop") != "stale-proof" assert create_response.request.url.query == b"source=httpx" diff --git a/tests/integration/direct/requests/test_requests_client.py b/tests/integration/direct/requests/test_requests_client.py index 0422af8..dd03198 100644 --- a/tests/integration/direct/requests/test_requests_client.py +++ b/tests/integration/direct/requests/test_requests_client.py @@ -62,7 +62,8 @@ def test_requests_lifecycle_attempts_delete_after_get_failure(oauth_environment: def test_requests_client_preserves_query_json_and_caller_headers(oauth_environment: OAuthTestEnvironment) -> None: - with _client(oauth_environment) as client: + config = oauth_environment.config("ec") + with _client(oauth_environment, config=config) as client: response = client.post( f"{oauth_environment.resource.base_url}/api/resources", params={"source": "requests"}, @@ -80,7 +81,7 @@ def test_requests_client_preserves_query_json_and_caller_headers(oauth_environme assert response.json() == {"id": "1", "name": "requests-resource"} assert recorded.path == "/api/resources?source=requests" assert recorded.body == b'{"name": "requests-resource"}' - assert recorded.header("user-agent") == "caller-agent" + assert recorded.header("user-agent") == config.user_agent assert recorded.header("authorization") == "DPoP integration-access-token" assert recorded.header("dpop") != "stale-proof" assert response.request.headers["Authorization"] == "DPoP integration-access-token" diff --git a/tests/integration/openapi/asyncio/test_openapi_asyncio_client.py b/tests/integration/openapi/asyncio/test_openapi_asyncio_client.py index 002969b..1bf4df1 100644 --- a/tests/integration/openapi/asyncio/test_openapi_asyncio_client.py +++ b/tests/integration/openapi/asyncio/test_openapi_asyncio_client.py @@ -90,8 +90,10 @@ async def exercise() -> None: def test_openapi_asyncio_client_preserves_query_json_and_caller_headers( oauth_environment: OAuthTestEnvironment, ) -> None: + config = oauth_environment.config("ec") + async def exercise() -> Resource: - client = _client(oauth_environment) + client = _client(oauth_environment, config=config) client.api.api_client.user_agent = "caller-agent" resource_id: str | None = None try: @@ -115,7 +117,7 @@ async def exercise() -> Resource: assert resource.name == "openapi-asyncio-resource" assert recorded.path == "/api/resources?source=openapi-asyncio" assert recorded.body == b'{"name": "openapi-asyncio-resource"}' - assert recorded.header("user-agent") == "caller-agent" + assert recorded.header("user-agent") == config.user_agent assert recorded.header("authorization") == "DPoP integration-access-token" assert recorded.header("dpop") != "stale-proof" diff --git a/tests/integration/openapi/httpx/test_openapi_httpx_client.py b/tests/integration/openapi/httpx/test_openapi_httpx_client.py index 3d8fc39..03dd05e 100644 --- a/tests/integration/openapi/httpx/test_openapi_httpx_client.py +++ b/tests/integration/openapi/httpx/test_openapi_httpx_client.py @@ -79,8 +79,10 @@ async def exercise() -> None: def test_openapi_httpx_client_preserves_query_json_and_caller_headers( oauth_environment: OAuthTestEnvironment, ) -> None: + config = oauth_environment.config("ec") + async def exercise() -> Resource: - client = _client(oauth_environment) + client = _client(oauth_environment, config=config) client.api.api_client.user_agent = "caller-agent" resource_id: str | None = None try: @@ -104,7 +106,7 @@ async def exercise() -> Resource: assert resource.name == "openapi-httpx-resource" assert recorded.path == "/api/resources?source=openapi-httpx" assert recorded.body == b'{"name":"openapi-httpx-resource"}' - assert recorded.header("user-agent") == "caller-agent" + assert recorded.header("user-agent") == config.user_agent assert recorded.header("authorization") == "DPoP integration-access-token" assert recorded.header("dpop") != "stale-proof" diff --git a/tests/integration/openapi/urllib3/test_openapi_urllib3_client.py b/tests/integration/openapi/urllib3/test_openapi_urllib3_client.py index 355f258..79cd65d 100644 --- a/tests/integration/openapi/urllib3/test_openapi_urllib3_client.py +++ b/tests/integration/openapi/urllib3/test_openapi_urllib3_client.py @@ -112,7 +112,8 @@ def test_openapi_client_does_not_follow_same_origin_redirect( def test_openapi_client_preserves_query_json_and_caller_headers(oauth_environment: OAuthTestEnvironment) -> None: - client = _client(oauth_environment) + config = oauth_environment.config("ec") + client = _client(oauth_environment, config=config) client.api.api_client.user_agent = "caller-agent" resource = client.api.create_resource( Resource(name="openapi-resource"), @@ -127,7 +128,7 @@ def test_openapi_client_preserves_query_json_and_caller_headers(oauth_environmen recorded = oauth_environment.resource.state.requests[-1] assert recorded.path == "/api/resources?source=openapi" assert recorded.body == b'{"name": "openapi-resource"}' - assert recorded.header("user-agent") == "caller-agent" + assert recorded.header("user-agent") == config.user_agent assert recorded.header("authorization") == "DPoP integration-access-token" assert recorded.header("dpop") != "stale-proof" finally: diff --git a/tests/unit/test_async_handler.py b/tests/unit/test_async_handler.py index 1a083c0..5c2c685 100644 --- a/tests/unit/test_async_handler.py +++ b/tests/unit/test_async_handler.py @@ -392,7 +392,7 @@ async def exercise() -> None: asyncio.run(exercise()) -def test_async_handler_preserves_caller_headers_and_replaces_oauth_headers() -> None: +def test_async_handler_preserves_caller_headers_and_replaces_controlled_headers() -> None: async def exercise() -> None: config = _config() dpop_key = config.dpop_key_provider.get_current_key() @@ -410,7 +410,7 @@ async def exercise() -> None: url="https://api.example.com/payments", headers={ "Accept": "application/json", - "User-Agent": "caller-agent", + "user-agent": "caller-agent", "Authorization": "Bearer stale", "DPoP": "stale-proof", }, @@ -421,7 +421,8 @@ async def exercise() -> None: headers = adapter.requests[0].headers assert headers["Accept"] == "application/json" - assert headers["User-Agent"] == "caller-agent" + assert headers["User-Agent"] == "library-agent" + assert "user-agent" not in headers assert headers["Authorization"] == "DPoP cached-token" assert headers["DPoP"] != "stale-proof" diff --git a/tests/unit/test_handler.py b/tests/unit/test_handler.py index 3691f4f..23a826f 100644 --- a/tests/unit/test_handler.py +++ b/tests/unit/test_handler.py @@ -194,7 +194,7 @@ def test_handler_reuses_matching_cached_token() -> None: sent = transport.requests[0] assert sent.url == _resource_request().url assert sent.headers["Authorization"] == "DPoP cached-token" - assert sent.headers["User-Agent"] == "caller-agent" + assert sent.headers["User-Agent"] == "library-agent" assert len(sent.headers["DPoP"].split(".")) == 3 From 14912017d349afed1bd06fc8b823503d57587807 Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Mon, 12 Oct 2026 10:00:52 +0530 Subject: [PATCH 34/36] Review comments: Protect live-test secrets with sandbox environment --- .github/workflows/live-test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/live-test.yml b/.github/workflows/live-test.yml index 7f1ca5f..4b8e2f8 100644 --- a/.github/workflows/live-test.yml +++ b/.github/workflows/live-test.yml @@ -45,6 +45,7 @@ jobs: live-tests: needs: build runs-on: ubuntu-latest + environment: sandbox strategy: fail-fast: false max-parallel: 3 From cecee4c78e298f9a83b0e0bbbfec4557ba5dee6a Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Mon, 12 Oct 2026 17:02:55 +0530 Subject: [PATCH 35/36] Defect G1548-13582 fix: Raise an error when the token endpoint fails --- README.md | 6 +++ src/mastercard_oauth2_client/__init__.py | 3 +- src/mastercard_oauth2_client/exceptions.py | 30 ++++++++++++ .../handlers/async_.py | 18 ++++---- src/mastercard_oauth2_client/handlers/sync.py | 18 ++++---- src/mastercard_oauth2_client/token.py | 44 ++++++++++++++++-- .../direct/aiohttp/test_aiohttp_client.py | 38 ++++++--------- .../direct/httpx/test_httpx_async_client.py | 38 ++++++--------- .../direct/httpx/test_httpx_sync_client.py | 32 +++++-------- .../direct/requests/test_requests_client.py | 31 +++++-------- .../asyncio/test_openapi_asyncio_client.py | 13 +++--- .../httpx/test_openapi_httpx_client.py | 13 +++--- .../urllib3/test_openapi_urllib3_client.py | 18 +++++--- tests/unit/test_async_handler.py | 29 ++++++++---- tests/unit/test_handler.py | 46 +++++++++++++++++-- tests/unit/test_token.py | 36 ++++++++++++++- 16 files changed, 264 insertions(+), 149 deletions(-) diff --git a/README.md b/README.md index 4388fb0..2e0ce45 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,12 @@ The library supports RSA and EC keys: The token endpoint must be an absolute HTTPS URL. Configuration errors raise `OAuth2ConfigError` with the invalid setting. +### Token endpoint errors + +When the token endpoint returns an HTTP error, the library raises +`OAuth2TokenEndpointError`. The exception includes the status code and any +OAuth error details or correlation ID returned by the server. + ### Requests: streaming uploads `OAuth2Session` supports bodies prepared as bytes or strings, including normal diff --git a/src/mastercard_oauth2_client/__init__.py b/src/mastercard_oauth2_client/__init__.py index 1ec5b4a..bd22b23 100644 --- a/src/mastercard_oauth2_client/__init__.py +++ b/src/mastercard_oauth2_client/__init__.py @@ -1,7 +1,7 @@ from .config import OAuth2Config, SecurityProfile from .defaults import StaticDPoPKeyProvider, StaticScopeResolver from .dpop import create_client_assertion, create_resource_dpop_proof, create_token_dpop_proof -from .exceptions import OAuth2ConfigError, OAuth2Error +from .exceptions import OAuth2ConfigError, OAuth2Error, OAuth2TokenEndpointError from .handlers import AsyncOAuth2Handler, OAuth2Handler from .integrations.openapi import add_oauth2_layer from .keys import ( @@ -39,6 +39,7 @@ "OAuth2ConfigError", "OAuth2Error", "OAuth2Handler", + "OAuth2TokenEndpointError", "PrivateKey", "PublicKey", "ScopeResolver", diff --git a/src/mastercard_oauth2_client/exceptions.py b/src/mastercard_oauth2_client/exceptions.py index f8c16fc..7f90a47 100644 --- a/src/mastercard_oauth2_client/exceptions.py +++ b/src/mastercard_oauth2_client/exceptions.py @@ -2,5 +2,35 @@ class OAuth2Error(Exception): """Report an OAuth2 operation failure produced by this library.""" +class OAuth2TokenEndpointError(OAuth2Error): + """Report an HTTP error returned by the token endpoint. + + Attributes: + status: HTTP status returned by the token endpoint. + error: OAuth error code, if supplied in the response body. + error_description: OAuth error description, if supplied. + correlation_id: Correlation identifier returned by the server, if supplied. + """ + + def __init__( + self, + *, + status: int, + error: str | None = None, + error_description: str | None = None, + correlation_id: str | None = None, + ) -> None: + message = f"Token request failed with HTTP {status}" + if error is not None: + message = f"{message}: {error}" + if error_description is not None: + message = f"{message} ({error_description})" + super().__init__(message) + self.status = status + self.error = error + self.error_description = error_description + self.correlation_id = correlation_id + + class OAuth2ConfigError(OAuth2Error, ValueError): """Report invalid OAuth2 configuration or unsupported key material.""" diff --git a/src/mastercard_oauth2_client/handlers/async_.py b/src/mastercard_oauth2_client/handlers/async_.py index 6620b62..e8da18a 100644 --- a/src/mastercard_oauth2_client/handlers/async_.py +++ b/src/mastercard_oauth2_client/handlers/async_.py @@ -3,7 +3,7 @@ from typing import Generic, TypeVar from .._internal._scopes import normalize_scopes -from .._internal.http import is_redirect_status, is_success_status +from .._internal.http import is_redirect_status from .._internal.orchestration import ( NonceStore, build_resource_headers, @@ -45,7 +45,11 @@ async def execute(self, request: RequestT, adapter: AsyncHttpAdapter[RequestT, R adapter: Adapter for the request's HTTP client. Returns: - Resource response, or the token endpoint response if token acquisition fails. + Resource response. + + Raises: + OAuth2TokenEndpointError: If the token endpoint returned an HTTP error. + OAuth2Error: If token acquisition otherwise fails. """ method = adapter.request_method(request) @@ -61,9 +65,6 @@ async def execute(self, request: RequestT, adapter: AsyncHttpAdapter[RequestT, R validate_dpop_key(dpop_key) _LOGGER.debug("DPoP key selected") access_token = await self._get_access_token(request, adapter, scopes, dpop_key) - if not isinstance(access_token, AccessToken): - # Token acquisition failed, so return the native token-endpoint response to the caller. - return access_token response = await self._send_resource_request(request, adapter, access_token, dpop_key) _LOGGER.info("Resource request completed with HTTP %d", adapter.response_status(response)) @@ -75,8 +76,8 @@ async def _get_access_token( adapter: AsyncHttpAdapter[RequestT, ResponseT], scopes: frozenset[str], dpop_key: DPoPKey, - ) -> AccessToken | ResponseT: - """Return a usable token, or the failed native token-endpoint response.""" + ) -> AccessToken: + """Return a usable token or raise if token acquisition fails.""" access_token = self._config.token_store.get(token_filter(scopes, dpop_key)) if access_token is not None: @@ -90,9 +91,6 @@ async def _get_access_token( if is_redirect_status(status): await adapter.close_response(response) raise OAuth2Error("Token endpoint redirects are not supported") - if not is_success_status(status): - return response - try: snapshot = await self._response_snapshot(adapter, response) finally: diff --git a/src/mastercard_oauth2_client/handlers/sync.py b/src/mastercard_oauth2_client/handlers/sync.py index 68b7cd8..1a33405 100644 --- a/src/mastercard_oauth2_client/handlers/sync.py +++ b/src/mastercard_oauth2_client/handlers/sync.py @@ -3,7 +3,7 @@ from typing import Generic, TypeVar from .._internal._scopes import normalize_scopes -from .._internal.http import is_redirect_status, is_success_status +from .._internal.http import is_redirect_status from .._internal.orchestration import ( NonceStore, build_resource_headers, @@ -45,7 +45,11 @@ def execute(self, request: RequestT, adapter: SyncHttpAdapter[RequestT, Response adapter: Adapter for the request's HTTP client. Returns: - Resource response, or the token endpoint response if token acquisition fails. + Resource response. + + Raises: + OAuth2TokenEndpointError: If the token endpoint returned an HTTP error. + OAuth2Error: If token acquisition otherwise fails. """ method = adapter.request_method(request) @@ -61,9 +65,6 @@ def execute(self, request: RequestT, adapter: SyncHttpAdapter[RequestT, Response validate_dpop_key(dpop_key) _LOGGER.debug("DPoP key selected") access_token = self._get_access_token(request, adapter, scopes, dpop_key) - if not isinstance(access_token, AccessToken): - # Token acquisition failed, so return the native token-endpoint response to the caller. - return access_token response = self._send_resource_request(request, adapter, access_token, dpop_key) _LOGGER.info("Resource request completed with HTTP %d", adapter.response_status(response)) @@ -75,8 +76,8 @@ def _get_access_token( adapter: SyncHttpAdapter[RequestT, ResponseT], scopes: frozenset[str], dpop_key: DPoPKey, - ) -> AccessToken | ResponseT: - """Return a usable token, or the failed native token-endpoint response.""" + ) -> AccessToken: + """Return a usable token or raise if token acquisition fails.""" access_token = self._config.token_store.get(token_filter(scopes, dpop_key)) if access_token is not None: @@ -90,9 +91,6 @@ def _get_access_token( if is_redirect_status(status): adapter.close_response(response) raise OAuth2Error("Token endpoint redirects are not supported") - if not is_success_status(status): - return response - try: snapshot = self._response_snapshot(adapter, response) finally: diff --git a/src/mastercard_oauth2_client/token.py b/src/mastercard_oauth2_client/token.py index 6b62a73..0843c70 100644 --- a/src/mastercard_oauth2_client/token.py +++ b/src/mastercard_oauth2_client/token.py @@ -8,11 +8,12 @@ from ._internal._clock import NowProvider as _NowProvider from ._internal._clock import utc_now as _utc_now from ._internal._scopes import normalize_scopes as _normalize_scopes +from ._internal.http import get_header, is_redirect_status from ._internal.http import is_success_status as _is_success_status from ._internal.jose import jwk_thumbprint from .config import OAuth2Config from .dpop import create_client_assertion, create_token_dpop_proof -from .exceptions import OAuth2Error +from .exceptions import OAuth2Error, OAuth2TokenEndpointError from .keys import validate_dpop_key from .models import AccessToken, HttpRequest, HttpResponse @@ -142,7 +143,8 @@ def parse_token_response( Access token with expiration, scopes, and DPoP binding metadata. Raises: - OAuth2Error: If the response is unsuccessful, malformed, or not a valid DPoP token response. + OAuth2TokenEndpointError: If the token endpoint returned an HTTP error. + OAuth2Error: If the response is a redirect, malformed, or not a valid DPoP token response. """ return _parse_token_response( @@ -162,8 +164,10 @@ def _parse_token_response( *, now_provider: _NowProvider, ) -> AccessToken: + if is_redirect_status(response.status): + raise OAuth2Error("Token endpoint redirects are not supported") if not _is_success_status(response.status): - raise OAuth2Error(f"Token request failed with HTTP {response.status}") + raise _token_endpoint_error(response) value = _read_token_response_json(response.body) access_token = _read_required_string(value, "access_token") @@ -173,13 +177,45 @@ def _parse_token_response( if token_type.casefold() != "dpop": raise OAuth2Error(f"Expected DPoP token type but received: {token_type}") expires_in = _read_expires_in(value) + granted_scopes = _read_granted_scopes(value, requested_scopes) dpop_key = config.dpop_key_provider.get_key(dpop_key_id) validate_dpop_key(dpop_key) return AccessToken( client_id=config.client_id, token_value=access_token, - scopes=_read_granted_scopes(value, requested_scopes), + scopes=granted_scopes, expires_at=now_provider() + timedelta(seconds=expires_in), jkt=jwk_thumbprint(dpop_key.key_pair.public_key), ) + + +def _token_endpoint_error(response: HttpResponse) -> OAuth2TokenEndpointError: + oauth_error, error_description = _token_error_fields(response.body) + correlation_id = get_header(response.headers, "Correlation-Id") or get_header( + response.headers, + "X-MC-Correlation-ID", + ) + return OAuth2TokenEndpointError( + status=response.status, + error=oauth_error, + error_description=error_description, + correlation_id=correlation_id, + ) + + +def _token_error_fields(body: bytes | str | None) -> tuple[str | None, str | None]: + if body is None or not body: + return None, None + try: + value = json.loads(body) + except (json.JSONDecodeError, UnicodeDecodeError): + return None, None + if not isinstance(value, dict): + return None, None + error = value.get("error") + description = value.get("error_description") + return ( + error if isinstance(error, str) else None, + description if isinstance(description, str) else None, + ) diff --git a/tests/integration/direct/aiohttp/test_aiohttp_client.py b/tests/integration/direct/aiohttp/test_aiohttp_client.py index c1afdef..ea5021f 100644 --- a/tests/integration/direct/aiohttp/test_aiohttp_client.py +++ b/tests/integration/direct/aiohttp/test_aiohttp_client.py @@ -13,7 +13,7 @@ import aiohttp_retry import pytest -from mastercard_oauth2_client import OAuth2Config, OAuth2Error +from mastercard_oauth2_client import OAuth2Config, OAuth2Error, OAuth2TokenEndpointError from mastercard_oauth2_client.integrations.aiohttp import create_oauth2_session from tests.integration.support.environment import OAuthTestEnvironment from tests.integration.support.lifecycle import run_async_resource_lifecycle @@ -243,40 +243,32 @@ async def exercise() -> list[int]: def test_aiohttp_client_stops_after_one_token_nonce_retry( oauth_environment: OAuthTestEnvironment, ) -> None: - async def exercise() -> int: + async def exercise() -> None: async with _client(oauth_environment) as client: - response = await client.get( - f"{oauth_environment.resource.base_url}/api/resources/missing", - headers={"Accept": "application/json"}, - ) - await response.read() - return response.status + await client.get(f"{oauth_environment.resource.base_url}/api/resources/missing") oauth_environment.authorization.state.always_challenge_nonce = True - assert asyncio.run(exercise()) == 400 + with pytest.raises(OAuth2TokenEndpointError) as error: + asyncio.run(exercise()) + assert error.value.status == 400 + assert error.value.error == "use_dpop_nonce" assert len(oauth_environment.authorization.state.requests) == 2 assert oauth_environment.resource.state.requests == [] -def test_aiohttp_client_returns_native_token_error(oauth_environment: OAuthTestEnvironment) -> None: - async def exercise() -> tuple[aiohttp.ClientResponse, dict[str, str]]: +def test_aiohttp_client_raises_structured_token_error(oauth_environment: OAuthTestEnvironment) -> None: + async def exercise() -> None: async with _client(oauth_environment) as client: - response = await client.get( - f"{oauth_environment.resource.base_url}/api/resources/missing", - headers={"Accept": "application/json"}, - ) - return response, await response.json() + await client.get(f"{oauth_environment.resource.base_url}/api/resources/missing") oauth_environment.authorization.state.reject_client_assertion = True - response, body = asyncio.run(exercise()) + with pytest.raises(OAuth2TokenEndpointError) as error: + asyncio.run(exercise()) - assert isinstance(response, aiohttp.ClientResponse) - assert response.status == 400 - assert body == { - "error": "invalid_client", - "error_description": "Client assertion was rejected", - } + assert error.value.status == 400 + assert error.value.error == "invalid_client" + assert error.value.error_description == "Client assertion was rejected" assert oauth_environment.resource.state.requests == [] diff --git a/tests/integration/direct/httpx/test_httpx_async_client.py b/tests/integration/direct/httpx/test_httpx_async_client.py index 306e317..859ec67 100644 --- a/tests/integration/direct/httpx/test_httpx_async_client.py +++ b/tests/integration/direct/httpx/test_httpx_async_client.py @@ -6,7 +6,7 @@ import httpx import pytest -from mastercard_oauth2_client import OAuth2Config +from mastercard_oauth2_client import OAuth2Config, OAuth2TokenEndpointError from mastercard_oauth2_client.integrations.httpx import AsyncOAuth2Transport from tests.integration.support.environment import OAuthTestEnvironment from tests.integration.support.lifecycle import run_async_resource_lifecycle @@ -200,42 +200,34 @@ async def exercise(config: OAuth2Config, store: InvalidatableTokenStore) -> tupl def test_async_httpx_client_stops_after_one_token_nonce_retry( oauth_environment: OAuthTestEnvironment, ) -> None: - async def exercise() -> httpx.Response: + async def exercise() -> None: async with _client(oauth_environment) as client: - return await client.get( - f"{oauth_environment.resource.base_url}/api/resources/missing", - headers={"Accept": "application/json"}, - ) + await client.get(f"{oauth_environment.resource.base_url}/api/resources/missing") oauth_environment.authorization.state.always_challenge_nonce = True - response = asyncio.run(exercise()) + with pytest.raises(OAuth2TokenEndpointError) as error: + asyncio.run(exercise()) - assert response.status_code == 400 - assert response.history == [] + assert error.value.status == 400 + assert error.value.error == "use_dpop_nonce" assert len(oauth_environment.authorization.state.requests) == 2 assert oauth_environment.resource.state.requests == [] -def test_async_httpx_client_returns_native_token_error_without_history( +def test_async_httpx_client_raises_structured_token_error( oauth_environment: OAuthTestEnvironment, ) -> None: - async def exercise() -> httpx.Response: + async def exercise() -> None: async with _client(oauth_environment) as client: - return await client.get( - f"{oauth_environment.resource.base_url}/api/resources/missing", - headers={"Accept": "application/json"}, - ) + await client.get(f"{oauth_environment.resource.base_url}/api/resources/missing") oauth_environment.authorization.state.reject_client_assertion = True - response = asyncio.run(exercise()) + with pytest.raises(OAuth2TokenEndpointError) as error: + asyncio.run(exercise()) - assert isinstance(response, httpx.Response) - assert response.status_code == 400 - assert response.history == [] - assert response.json() == { - "error": "invalid_client", - "error_description": "Client assertion was rejected", - } + assert error.value.status == 400 + assert error.value.error == "invalid_client" + assert error.value.error_description == "Client assertion was rejected" assert oauth_environment.resource.state.requests == [] diff --git a/tests/integration/direct/httpx/test_httpx_sync_client.py b/tests/integration/direct/httpx/test_httpx_sync_client.py index abb373c..66b9ab5 100644 --- a/tests/integration/direct/httpx/test_httpx_sync_client.py +++ b/tests/integration/direct/httpx/test_httpx_sync_client.py @@ -5,7 +5,7 @@ import httpx import pytest -from mastercard_oauth2_client import OAuth2Config +from mastercard_oauth2_client import OAuth2Config, OAuth2TokenEndpointError from mastercard_oauth2_client.integrations.httpx import OAuth2Transport from tests.integration.support.environment import OAuthTestEnvironment from tests.integration.support.lifecycle import run_resource_lifecycle @@ -129,34 +129,24 @@ def test_httpx_client_reacquires_invalidated_token(oauth_environment: OAuthTestE def test_httpx_client_stops_after_one_token_nonce_retry(oauth_environment: OAuthTestEnvironment) -> None: oauth_environment.authorization.state.always_challenge_nonce = True - with _client(oauth_environment) as client: - response = client.get( - f"{oauth_environment.resource.base_url}/api/resources/missing", - headers={"Accept": "application/json"}, - ) + with _client(oauth_environment) as client, pytest.raises(OAuth2TokenEndpointError) as error: + client.get(f"{oauth_environment.resource.base_url}/api/resources/missing") - assert response.status_code == 400 - assert response.history == [] + assert error.value.status == 400 + assert error.value.error == "use_dpop_nonce" assert len(oauth_environment.authorization.state.requests) == 2 assert oauth_environment.resource.state.requests == [] -def test_httpx_client_returns_native_token_error_without_history(oauth_environment: OAuthTestEnvironment) -> None: +def test_httpx_client_raises_structured_token_error(oauth_environment: OAuthTestEnvironment) -> None: oauth_environment.authorization.state.reject_client_assertion = True - with _client(oauth_environment) as client: - response = client.get( - f"{oauth_environment.resource.base_url}/api/resources/missing", - headers={"Accept": "application/json"}, - ) + with _client(oauth_environment) as client, pytest.raises(OAuth2TokenEndpointError) as error: + client.get(f"{oauth_environment.resource.base_url}/api/resources/missing") - assert isinstance(response, httpx.Response) - assert response.status_code == 400 - assert response.history == [] - assert response.json() == { - "error": "invalid_client", - "error_description": "Client assertion was rejected", - } + assert error.value.status == 400 + assert error.value.error == "invalid_client" + assert error.value.error_description == "Client assertion was rejected" assert oauth_environment.resource.state.requests == [] diff --git a/tests/integration/direct/requests/test_requests_client.py b/tests/integration/direct/requests/test_requests_client.py index dd03198..00df916 100644 --- a/tests/integration/direct/requests/test_requests_client.py +++ b/tests/integration/direct/requests/test_requests_client.py @@ -6,7 +6,7 @@ import pytest import requests -from mastercard_oauth2_client import OAuth2Config +from mastercard_oauth2_client import OAuth2Config, OAuth2TokenEndpointError from mastercard_oauth2_client.integrations.requests import OAuth2Session from tests.integration.support.environment import OAuthTestEnvironment from tests.integration.support.lifecycle import run_resource_lifecycle @@ -170,33 +170,24 @@ def test_requests_client_reacquires_invalidated_token(oauth_environment: OAuthTe def test_requests_client_stops_after_one_token_nonce_retry(oauth_environment: OAuthTestEnvironment) -> None: oauth_environment.authorization.state.always_challenge_nonce = True - with _client(oauth_environment) as client: - response = client.get( - f"{oauth_environment.resource.base_url}/api/resources/missing", - headers={"Accept": "application/json"}, - ) + with _client(oauth_environment) as client, pytest.raises(OAuth2TokenEndpointError) as error: + client.get(f"{oauth_environment.resource.base_url}/api/resources/missing") - assert response.status_code == 400 - assert response.history == [] + assert error.value.status == 400 + assert error.value.error == "use_dpop_nonce" assert len(oauth_environment.authorization.state.requests) == 2 assert oauth_environment.resource.state.requests == [] -def test_requests_client_returns_native_token_error(oauth_environment: OAuthTestEnvironment) -> None: +def test_requests_client_raises_structured_token_error(oauth_environment: OAuthTestEnvironment) -> None: oauth_environment.authorization.state.reject_client_assertion = True - with _client(oauth_environment) as client: - response = client.get( - f"{oauth_environment.resource.base_url}/api/resources/missing", - headers={"Accept": "application/json"}, - ) + with _client(oauth_environment) as client, pytest.raises(OAuth2TokenEndpointError) as error: + client.get(f"{oauth_environment.resource.base_url}/api/resources/missing") - assert isinstance(response, requests.Response) - assert response.status_code == 400 - assert response.json() == { - "error": "invalid_client", - "error_description": "Client assertion was rejected", - } + assert error.value.status == 400 + assert error.value.error == "invalid_client" + assert error.value.error_description == "Client assertion was rejected" assert oauth_environment.resource.state.requests == [] diff --git a/tests/integration/openapi/asyncio/test_openapi_asyncio_client.py b/tests/integration/openapi/asyncio/test_openapi_asyncio_client.py index 1bf4df1..e88dbea 100644 --- a/tests/integration/openapi/asyncio/test_openapi_asyncio_client.py +++ b/tests/integration/openapi/asyncio/test_openapi_asyncio_client.py @@ -18,7 +18,7 @@ ) from generated_asyncio_test_client.exceptions import ApiException # type: ignore[import-not-found] -from mastercard_oauth2_client import OAuth2Config, OAuth2Error +from mastercard_oauth2_client import OAuth2Config, OAuth2Error, OAuth2TokenEndpointError from mastercard_oauth2_client.integrations.openapi.asyncio import add_oauth2_layer from tests.integration.support.environment import OAuthTestEnvironment from tests.integration.support.lifecycle import run_async_resource_lifecycle @@ -209,10 +209,11 @@ async def exercise() -> None: oauth_environment.authorization.state.always_challenge_nonce = True - with pytest.raises(ApiException) as error: + with pytest.raises(OAuth2TokenEndpointError) as error: asyncio.run(exercise()) assert error.value.status == 400 + assert error.value.error == "use_dpop_nonce" assert len(oauth_environment.authorization.state.requests) == 2 assert oauth_environment.resource.state.requests == [] @@ -228,13 +229,11 @@ async def create(name: str) -> None: await client.close() oauth_environment.authorization.state.reject_client_assertion = True - with pytest.raises(ApiException) as assertion_error: + with pytest.raises(OAuth2TokenEndpointError) as assertion_error: asyncio.run(create("rejected")) assert assertion_error.value.status == 400 - assert json.loads(assertion_error.value.body) == { - "error": "invalid_client", - "error_description": "Client assertion was rejected", - } + assert assertion_error.value.error == "invalid_client" + assert assertion_error.value.error_description == "Client assertion was rejected" oauth_environment.reset() oauth_environment.resource.state.insufficient_scope = True diff --git a/tests/integration/openapi/httpx/test_openapi_httpx_client.py b/tests/integration/openapi/httpx/test_openapi_httpx_client.py index 03dd05e..a70ecd8 100644 --- a/tests/integration/openapi/httpx/test_openapi_httpx_client.py +++ b/tests/integration/openapi/httpx/test_openapi_httpx_client.py @@ -8,7 +8,7 @@ from generated_httpx_test_client import Resource # type: ignore[import-not-found] from generated_httpx_test_client.exceptions import ApiException # type: ignore[import-not-found] -from mastercard_oauth2_client import OAuth2Config +from mastercard_oauth2_client import OAuth2Config, OAuth2TokenEndpointError from tests.integration.support.environment import OAuthTestEnvironment from tests.integration.support.lifecycle import run_async_resource_lifecycle from tests.integration.support.lifecycle_clients.openapi_httpx import OpenApiHttpxLifecycleClient @@ -178,10 +178,11 @@ async def exercise() -> None: oauth_environment.authorization.state.always_challenge_nonce = True - with pytest.raises(ApiException) as error: + with pytest.raises(OAuth2TokenEndpointError) as error: asyncio.run(exercise()) assert error.value.status == 400 + assert error.value.error == "use_dpop_nonce" assert len(oauth_environment.authorization.state.requests) == 2 assert oauth_environment.resource.state.requests == [] @@ -197,13 +198,11 @@ async def create(name: str) -> None: await client.close() oauth_environment.authorization.state.reject_client_assertion = True - with pytest.raises(ApiException) as assertion_error: + with pytest.raises(OAuth2TokenEndpointError) as assertion_error: asyncio.run(create("rejected")) assert assertion_error.value.status == 400 - assert json.loads(assertion_error.value.body) == { - "error": "invalid_client", - "error_description": "Client assertion was rejected", - } + assert assertion_error.value.error == "invalid_client" + assert assertion_error.value.error_description == "Client assertion was rejected" oauth_environment.reset() oauth_environment.resource.state.insufficient_scope = True diff --git a/tests/integration/openapi/urllib3/test_openapi_urllib3_client.py b/tests/integration/openapi/urllib3/test_openapi_urllib3_client.py index 79cd65d..0e3245a 100644 --- a/tests/integration/openapi/urllib3/test_openapi_urllib3_client.py +++ b/tests/integration/openapi/urllib3/test_openapi_urllib3_client.py @@ -9,7 +9,12 @@ from generated_test_client.exceptions import ApiException # type: ignore[import-not-found] from urllib3._collections import HTTPHeaderDict -from mastercard_oauth2_client import OAuth2Config, build_token_request, create_resource_dpop_proof +from mastercard_oauth2_client import ( + OAuth2Config, + OAuth2TokenEndpointError, + build_token_request, + create_resource_dpop_proof, +) from tests.integration.support.environment import OAuthTestEnvironment from tests.integration.support.lifecycle import run_resource_lifecycle from tests.integration.support.lifecycle_clients.openapi_urllib3 import OpenApiLifecycleClient @@ -168,13 +173,11 @@ def test_openapi_client_returns_configured_server_errors(oauth_environment: OAut """Preserve authorization-server and resource-server failures for callers.""" oauth_environment.authorization.state.reject_client_assertion = True - with pytest.raises(ApiException) as assertion_error: + with pytest.raises(OAuth2TokenEndpointError) as assertion_error: _client(oauth_environment).create_resource("rejected") assert assertion_error.value.status == 400 - assert json.loads(assertion_error.value.body) == { - "error": "invalid_client", - "error_description": "Client assertion was rejected", - } + assert assertion_error.value.error == "invalid_client" + assert assertion_error.value.error_description == "Client assertion was rejected" oauth_environment.reset() oauth_environment.resource.state.insufficient_scope = True @@ -193,10 +196,11 @@ def test_openapi_client_stops_after_one_nonce_retry(oauth_environment: OAuthTest oauth_environment.authorization.state.always_challenge_nonce = True - with pytest.raises(ApiException) as error: + with pytest.raises(OAuth2TokenEndpointError) as error: _client(oauth_environment).create_resource("never-created") assert error.value.status == 400 + assert error.value.error == "use_dpop_nonce" assert len(oauth_environment.authorization.state.requests) == 2 assert oauth_environment.resource.state.requests == [] diff --git a/tests/unit/test_async_handler.py b/tests/unit/test_async_handler.py index 5c2c685..d49c52f 100644 --- a/tests/unit/test_async_handler.py +++ b/tests/unit/test_async_handler.py @@ -20,6 +20,7 @@ KeyPair, OAuth2Config, OAuth2Error, + OAuth2TokenEndpointError, StaticDPoPKeyProvider, StaticScopeResolver, parse_token_response, @@ -327,27 +328,37 @@ async def exercise() -> None: second = _challenge("second") adapter = AsyncScriptedAdapter([first, second]) - response = await AsyncOAuth2Handler[NativeRequest, NativeResponse](_config()).execute(_request(), adapter) + with pytest.raises(OAuth2TokenEndpointError) as error: + await AsyncOAuth2Handler[NativeRequest, NativeResponse](_config()).execute(_request(), adapter) - assert response is second + assert error.value.status == 401 + assert error.value.error == "use_dpop_nonce" assert first.closed - assert not second.closed + assert second.closed assert adapter.token_requests == 2 assert len(adapter.requests) == 2 asyncio.run(exercise()) -def test_async_handler_returns_terminal_token_error_open() -> None: +def test_async_handler_raises_structured_terminal_token_error_and_closes_response() -> None: async def exercise() -> None: - token_error = NativeResponse(status=403, headers={}, body="denied") + token_error = NativeResponse( + status=403, + headers={"X-MC-Correlation-ID": "correlation-id"}, + body='{"error":"invalid_client","error_description":"Client assertion was rejected"}', + ) adapter = AsyncScriptedAdapter([token_error]) - response = await AsyncOAuth2Handler[NativeRequest, NativeResponse](_config()).execute(_request(), adapter) + with pytest.raises(OAuth2TokenEndpointError) as error: + await AsyncOAuth2Handler[NativeRequest, NativeResponse](_config()).execute(_request(), adapter) - assert response is token_error - assert not response.closed - assert response.body_reads == 0 + assert error.value.status == 403 + assert error.value.error == "invalid_client" + assert error.value.error_description == "Client assertion was rejected" + assert error.value.correlation_id == "correlation-id" + assert token_error.closed + assert token_error.body_reads == 1 assert len(adapter.requests) == 1 asyncio.run(exercise()) diff --git a/tests/unit/test_handler.py b/tests/unit/test_handler.py index 23a826f..3448cd7 100644 --- a/tests/unit/test_handler.py +++ b/tests/unit/test_handler.py @@ -22,6 +22,7 @@ OAuth2Config, OAuth2Error, OAuth2Handler, + OAuth2TokenEndpointError, StaticDPoPKeyProvider, StaticScopeResolver, parse_token_response, @@ -276,6 +277,39 @@ def test_handler_retries_token_request_once_with_server_nonce(challenge_status: assert assertions[0] != assertions[1] +def test_handler_raises_structured_error_when_nonce_retry_returns_invalid_client() -> None: + config = _config() + first_challenge = _nonce_challenge("token-nonce", status=400) + invalid_client = NativeResponse( + status=400, + headers={"Correlation-Id": "correlation-id"}, + body=json.dumps( + { + "error": "invalid_client", + "error_description": "Client assertion signature could not be verified", + } + ), + ) + transport = ScriptedAdapter([first_challenge, invalid_client]) + + with pytest.raises(OAuth2TokenEndpointError) as error: + OAuth2Handler[NativeRequest, NativeResponse](config).execute(_resource_request(), transport) + + assert error.value.status == 400 + assert str(error.value) == ( + "Token request failed with HTTP 400: invalid_client " + "(Client assertion signature could not be verified)" + ) + assert error.value.error == "invalid_client" + assert error.value.error_description == "Client assertion signature could not be verified" + assert error.value.correlation_id == "correlation-id" + assert _decode_claims(transport.requests[1].headers["DPoP"])["nonce"] == "token-nonce" + assert first_challenge.closed + assert invalid_client.closed + assert len(transport.requests) == 2 + assert all(request.url == config.token_endpoint for request in transport.requests) + + @pytest.mark.parametrize("challenge_status", [400, 401]) def test_handler_retries_resource_request_once_with_server_nonce(challenge_status: int) -> None: config = _config() @@ -382,18 +416,20 @@ def test_handler_stops_after_one_resource_nonce_retry() -> None: assert len(transport.requests) == 3 -def test_handler_returns_second_token_challenge_without_sending_resource_request() -> None: +def test_handler_raises_for_second_token_challenge_without_sending_resource_request() -> None: config = _config() first_challenge = _nonce_challenge("first") second_challenge = _nonce_challenge("second") transport = ScriptedAdapter([first_challenge, second_challenge]) - response = OAuth2Handler[NativeRequest, NativeResponse](config).execute(_resource_request(), transport) + with pytest.raises(OAuth2TokenEndpointError) as error: + OAuth2Handler[NativeRequest, NativeResponse](config).execute(_resource_request(), transport) - assert response is second_challenge - assert response.status == 401 + assert error.value.status == 401 + assert error.value.error == "use_dpop_nonce" assert first_challenge.closed - assert not second_challenge.closed + assert second_challenge.closed + assert second_challenge.body_reads == 1 assert len(transport.requests) == 2 assert all(request.url == config.token_endpoint for request in transport.requests) diff --git a/tests/unit/test_token.py b/tests/unit/test_token.py index 7ba1cfa..4718f81 100644 --- a/tests/unit/test_token.py +++ b/tests/unit/test_token.py @@ -10,6 +10,7 @@ KeyPair, OAuth2Config, OAuth2Error, + OAuth2TokenEndpointError, StaticDPoPKeyProvider, StaticScopeResolver, build_token_request, @@ -215,13 +216,44 @@ def test_parse_token_response_rejects_unsuccessful_or_malformed_response() -> No config = _config() dpop_key_id = config.dpop_key_provider.get_current_key().key_id - with pytest.raises(OAuth2Error, match="Token request failed with HTTP 400"): - parse_token_response(config, _response({"error": "invalid_client"}, status=400), set(), dpop_key_id) + error_response = HttpResponse( + status=400, + headers={"Correlation-Id": "correlation-id"}, + body=json.dumps( + { + "error": "invalid_client", + "error_description": "Client assertion was rejected", + } + ), + ) + with pytest.raises(OAuth2TokenEndpointError, match="Token request failed with HTTP 400") as token_error: + parse_token_response(config, error_response, set(), dpop_key_id) + + assert token_error.value.status == 400 + assert token_error.value.error == "invalid_client" + assert token_error.value.error_description == "Client assertion was rejected" + assert token_error.value.correlation_id == "correlation-id" with pytest.raises(OAuth2Error, match="Failed to parse JSON access token response"): parse_token_response(config, HttpResponse(status=200, headers={}, body="not-json"), set(), dpop_key_id) +@pytest.mark.parametrize("body", [None, "", "service unavailable", "[]"]) +def test_parse_token_response_preserves_status_for_unstructured_endpoint_error( + body: str | None, +) -> None: + config = _config() + dpop_key_id = config.dpop_key_provider.get_current_key().key_id + + with pytest.raises(OAuth2TokenEndpointError, match="Token request failed with HTTP 503") as error: + parse_token_response(config, HttpResponse(status=503, headers={}, body=body), set(), dpop_key_id) + + assert error.value.status == 503 + assert error.value.error is None + assert error.value.error_description is None + assert error.value.correlation_id is None + + @pytest.mark.parametrize("body", [None, "", " ", b""]) def test_parse_token_response_rejects_empty_body(body: bytes | str | None) -> None: config = _config() From 470c6e1e25dc7bf94465108ea6fe49d2ae441458 Mon Sep 17 00:00:00 2001 From: Rudra Shimona Date: Mon, 19 Oct 2026 17:11:12 +0530 Subject: [PATCH 36/36] Review comments: requests: fix redirect nonce retry --- src/mastercard_oauth2_client/handlers/sync.py | 23 +++++- .../integrations/requests.py | 7 ++ .../direct/requests/test_requests_client.py | 70 ++++++++++++++++++- tests/integration/support/models.py | 4 ++ tests/integration/support/resource_server.py | 16 ++++- tests/unit/test_handler.py | 35 ++++++++++ 6 files changed, 150 insertions(+), 5 deletions(-) diff --git a/src/mastercard_oauth2_client/handlers/sync.py b/src/mastercard_oauth2_client/handlers/sync.py index 1a33405..da766e4 100644 --- a/src/mastercard_oauth2_client/handlers/sync.py +++ b/src/mastercard_oauth2_client/handlers/sync.py @@ -1,6 +1,6 @@ import logging -from collections.abc import Mapping -from typing import Generic, TypeVar +from collections.abc import Callable, Mapping +from typing import Generic, TypeVar, cast from .._internal._scopes import normalize_scopes from .._internal.http import is_redirect_status @@ -136,15 +136,32 @@ def _send_resource_request( headers = self._resource_headers(request, adapter, access_token, dpop_key) response = adapter.send_resource_request(request, headers) + if not self._response_matches_request(request, response, adapter): + return response response_nonce = self._update_nonce(adapter.response_headers(response)) if self._is_nonce_challenge(adapter, response): adapter.close_response(response) _LOGGER.info("DPoP nonce-directed retry for resource request") retry_headers = self._resource_headers(request, adapter, access_token, dpop_key, response_nonce) response = adapter.send_resource_request(request, retry_headers) - self._update_nonce(adapter.response_headers(response)) + if self._response_matches_request(request, response, adapter): + self._update_nonce(adapter.response_headers(response)) return response + @staticmethod + def _response_matches_request( + request: RequestT, + response: ResponseT, + adapter: SyncHttpAdapter[RequestT, ResponseT], + ) -> bool: + """Return whether the response belongs to the request handled here.""" + + matcher = cast( + Callable[[RequestT, ResponseT], bool] | None, + getattr(adapter, "_response_matches_request", None), + ) + return matcher is None or matcher(request, response) + def _response_snapshot( self, adapter: SyncHttpAdapter[RequestT, ResponseT], diff --git a/src/mastercard_oauth2_client/integrations/requests.py b/src/mastercard_oauth2_client/integrations/requests.py index 4ec326e..4be8c7a 100644 --- a/src/mastercard_oauth2_client/integrations/requests.py +++ b/src/mastercard_oauth2_client/integrations/requests.py @@ -67,6 +67,13 @@ def send_resource_request( request.headers.update(headers) return requests.Session.send(self._session, request, **self._resource_settings) + def _response_matches_request( + self, + request: requests.PreparedRequest, + response: requests.Response, + ) -> bool: + return response.request is request + def response_status(self, response: requests.Response) -> int: return response.status_code diff --git a/tests/integration/direct/requests/test_requests_client.py b/tests/integration/direct/requests/test_requests_client.py index 00df916..a0a87a1 100644 --- a/tests/integration/direct/requests/test_requests_client.py +++ b/tests/integration/direct/requests/test_requests_client.py @@ -317,4 +317,72 @@ def test_requests_client_preserves_cookies_on_same_origin_redirect( ) assert response.status_code == 404 - assert oauth_environment.resource.state.requests[-1].header("cookie") == "redirected=true" \ No newline at end of file + assert oauth_environment.resource.state.requests[-1].header("cookie") == "redirected=true" + + +def test_requests_client_does_not_replay_redirect_source_after_destination_nonce_retry_fails( + oauth_environment: OAuthTestEnvironment, +) -> None: + get_dog_path = "/api/resources/dog-id" + + with _client(oauth_environment) as client: + # Prime the resource nonce so the redirect source succeeds immediately. + client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + oauth_environment.resource.state.requests.clear() + oauth_environment.resource.state.redirect_location = ( + f"{oauth_environment.resource.base_url}{get_dog_path}" + ) + oauth_environment.resource.state.nonce_challenge_paths.add(get_dog_path) + + response = client.get( + f"{oauth_environment.resource.base_url}{REDIRECT_PATH}", + headers={"Accept": "application/json"}, + ) + + assert response.status_code == 401 + assert response.headers["DPoP-Nonce"] == oauth_environment.resource.state.nonce + assert response.headers["WWW-Authenticate"] == 'DPoP error="use_dpop_nonce"' + assert response.json() == {"error": "use_dpop_nonce"} + assert [request.path for request in oauth_environment.resource.state.requests] == [ + REDIRECT_PATH, + get_dog_path, + get_dog_path, + ] + + +def test_requests_client_does_not_replay_source_after_cross_origin_destination_nonce_challenge( + oauth_environment: OAuthTestEnvironment, +) -> None: + get_dog_path = "/api/resources/cross-origin-dog-id" + redirected_url = oauth_environment.resource.base_url.replace("127.0.0.1", "localhost") + + with _client(oauth_environment) as client: + client.get( + f"{oauth_environment.resource.base_url}/api/resources/missing", + headers={"Accept": "application/json"}, + ) + oauth_environment.resource.state.requests.clear() + oauth_environment.resource.state.redirect_location = f"{redirected_url}{get_dog_path}" + oauth_environment.resource.state.unauthenticated_nonce_challenge_paths.add(get_dog_path) + + response = client.get( + f"{oauth_environment.resource.base_url}{REDIRECT_PATH}", + headers={"Accept": "application/json"}, + ) + + assert response.status_code == 401 + assert response.headers["DPoP-Nonce"] == oauth_environment.resource.state.nonce + assert response.headers["WWW-Authenticate"] == 'DPoP error="use_dpop_nonce"' + assert response.json() == {"error": "use_dpop_nonce"} + assert [request.path for request in oauth_environment.resource.state.requests] == [ + REDIRECT_PATH, + get_dog_path, + ] + source_request, destination_request = oauth_environment.resource.state.requests + assert source_request.header("authorization") is not None + assert source_request.header("dpop") is not None + assert destination_request.header("authorization") is None + assert destination_request.header("dpop") is None \ No newline at end of file diff --git a/tests/integration/support/models.py b/tests/integration/support/models.py index e8dd456..4d6a7ef 100644 --- a/tests/integration/support/models.py +++ b/tests/integration/support/models.py @@ -63,6 +63,8 @@ class ResourceState: redirect_location: str | None = None redirect_locations: dict[str, str] = field(default_factory=dict) unauthenticated_redirect_locations: dict[str, str] = field(default_factory=dict) + unauthenticated_nonce_challenge_paths: set[str] = field(default_factory=set) + nonce_challenge_paths: set[str] = field(default_factory=set) def reset(self) -> None: self.requests.clear() @@ -74,6 +76,8 @@ def reset(self) -> None: self.redirect_location = None self.redirect_locations.clear() self.unauthenticated_redirect_locations.clear() + self.unauthenticated_nonce_challenge_paths.clear() + self.nonce_challenge_paths.clear() DPoPAlgorithm: TypeAlias = Literal["ec", "rsa"] diff --git a/tests/integration/support/resource_server.py b/tests/integration/support/resource_server.py index ea60159..460c801 100644 --- a/tests/integration/support/resource_server.py +++ b/tests/integration/support/resource_server.py @@ -56,6 +56,16 @@ def _handle(self) -> None: if redirect_location is not None: self._send_empty(307, {"Location": redirect_location}) return + if path in server.state.unauthenticated_nonce_challenge_paths: + self._send_json( + 401, + {"error": "use_dpop_nonce"}, + { + "DPoP-Nonce": server.state.nonce, + "WWW-Authenticate": 'DPoP error="use_dpop_nonce"', + }, + ) + return try: self._validate_request(server.state, request) except JwtValidationError as error: @@ -74,7 +84,11 @@ def _handle(self) -> None: return dpop = verify_jwt(_required_header(request, "dpop")) - if server.state.always_challenge_nonce or dpop.claims.get("nonce") != server.state.nonce: + if ( + server.state.always_challenge_nonce + or path in server.state.nonce_challenge_paths + or dpop.claims.get("nonce") != server.state.nonce + ): self._send_json( 401, {"error": "use_dpop_nonce"}, diff --git a/tests/unit/test_handler.py b/tests/unit/test_handler.py index 3448cd7..b395a25 100644 --- a/tests/unit/test_handler.py +++ b/tests/unit/test_handler.py @@ -50,6 +50,7 @@ class NativeResponse: body: bytes | str | None = None body_reads: int = 0 closed: bool = False + matches_request: bool = True class ScriptedAdapter: @@ -83,6 +84,9 @@ def send_resource_request(self, request: NativeRequest, headers: Mapping[str, st def response_status(self, response: NativeResponse) -> int: return response.status + def _response_matches_request(self, request: NativeRequest, response: NativeResponse) -> bool: + return response.matches_request + def response_headers(self, response: NativeResponse) -> Mapping[str, str]: return response.headers @@ -416,6 +420,37 @@ def test_handler_stops_after_one_resource_nonce_retry() -> None: assert len(transport.requests) == 3 +def test_handler_does_not_retry_request_for_foreign_nonce_challenge() -> None: + config = _config() + foreign_challenge = _nonce_challenge("foreign-nonce") + foreign_challenge.matches_request = False + transport = ScriptedAdapter([_token_response(), foreign_challenge]) + + response = OAuth2Handler[NativeRequest, NativeResponse](config).execute(_resource_request(), transport) + + assert response is foreign_challenge + assert response.headers["dpop-nonce"] == "foreign-nonce" + assert response.body == '{"error": "use_dpop_nonce", "secret": "response-secret"}' + assert response.body_reads == 0 + assert not response.closed + assert len(transport.requests) == 2 + + +def test_handler_does_not_store_foreign_nonce_returned_by_resource_retry() -> None: + config = _config() + first_challenge = _nonce_challenge("resource-nonce") + foreign_challenge = _nonce_challenge("foreign-nonce") + foreign_challenge.matches_request = False + transport = ScriptedAdapter([_token_response(), first_challenge, foreign_challenge]) + handler = OAuth2Handler[NativeRequest, NativeResponse](config) + + response = handler.execute(_resource_request(), transport) + + assert response is foreign_challenge + assert handler._nonces.get() == "resource-nonce" + assert len(transport.requests) == 3 + + def test_handler_raises_for_second_token_challenge_without_sending_resource_request() -> None: config = _config() first_challenge = _nonce_challenge("first")