diff --git a/CHANGELOG.md b/CHANGELOG.md index 8783b70..ab27d37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/). Releases prior to `1.0.0` were published before this changelog was added and are not documented here. +## [Unreleased] + +### Removed + +- `authlib` is no longer a dependency. The SDK only used it to fetch its access + token, so it now makes that client-credentials request with `httpx`. This + drops authlib (and `joserfc`) from your dependency tree, including + CVE-2026-96760, which the SDK's code path never reached. +- `AsyncFragmentClient.oauth2_client` and `SyncFragmentClient.oauth2_client` are + gone along with authlib. + +### Changed + +- A failed token request now raises `fragment.exceptions.TokenRequestException` + (with `.error` and `.description`) instead of authlib's `OAuthError`. Server + errors from the token endpoint still raise `httpx.HTTPStatusError`. + ## [1.4.0] ### Added diff --git a/fragment/client/async_client.py b/fragment/client/async_client.py index cd2f3e9..f02e5f9 100644 --- a/fragment/client/async_client.py +++ b/fragment/client/async_client.py @@ -1,5 +1,3 @@ -# Ignore untyped authlib -# mypy: disable-error-code="import-untyped" import time from typing import Any, Dict, Optional @@ -7,8 +5,8 @@ from ariadne_codegen.client_generators.dependencies.async_base_client import ( AsyncBaseClient, ) -from authlib.integrations.httpx_client import AsyncOAuth2Client +from fragment.client.oauth import fetch_token_async from fragment.exceptions import MissingArgumentException, MissingTokenException @@ -37,16 +35,18 @@ def __init__( self.auth_url = auth_url self.expiration_time: Optional[float] = None self.token: Optional[Dict[str, Any]] = None - self.oauth2_client = AsyncOAuth2Client( - client_id, client_secret, scope=auth_scope - ) + self.client_id = client_id + self.client_secret = client_secret + self.auth_scope = auth_scope async def refresh_token(self) -> None: now = time.time() if self.expiration_time is None or self.expiration_time <= now: # Held in a local because `self.token` is declared `dict | None`, # so reading the attribute back is not narrowed by the assignment. - token = await self.oauth2_client.fetch_token(self.auth_url) + token = await fetch_token_async( + self.auth_url, self.client_id, self.client_secret, self.auth_scope + ) self.token = token self.expiration_time = now + token["expires_in"] diff --git a/fragment/client/oauth.py b/fragment/client/oauth.py new file mode 100644 index 0000000..9ec730a --- /dev/null +++ b/fragment/client/oauth.py @@ -0,0 +1,75 @@ +"""OAuth2 client-credentials token request against the Fragment auth endpoint. + +The SDK needs exactly one OAuth2 call: exchange a client ID and secret for an +access token. That is a single form POST, so it is made with httpx directly +rather than through an OAuth library, whose much larger surface (JOSE, OIDC, +server-side grants) customers would otherwise inherit, along with its +advisories. + +The request matches what authlib's httpx `OAuth2Client.fetch_token` sent: +HTTP Basic client authentication (`client_secret_basic`) and a form body of +`grant_type=client_credentials` and `scope`. +""" + +from typing import Any, Dict + +import httpx + +from fragment.exceptions import TokenRequestException + +_HEADERS = { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", +} + + +def _token_request_kwargs( + client_id: str, client_secret: str, scope: str +) -> Dict[str, Any]: + return dict( + data={"grant_type": "client_credentials", "scope": scope}, + auth=httpx.BasicAuth(client_id, client_secret), + headers=_HEADERS, + ) + + +def _parse_token_response(response: httpx.Response) -> Dict[str, Any]: + if response.status_code >= 500: + response.raise_for_status() + try: + token = response.json() + except ValueError as e: + raise TokenRequestException( + f"token endpoint returned a non-JSON response " + f"(HTTP {response.status_code})" + ) from e + if not isinstance(token, dict): + raise TokenRequestException("token endpoint returned a non-object response") + if "error" in token: + raise TokenRequestException(token["error"], token.get("error_description")) + if "access_token" not in token or "expires_in" not in token: + raise TokenRequestException( + f"token endpoint response is missing access_token or expires_in " + f"(HTTP {response.status_code})" + ) + return token + + +def fetch_token( + auth_url: str, client_id: str, client_secret: str, scope: str +) -> Dict[str, Any]: + with httpx.Client() as client: + response = client.post( + auth_url, **_token_request_kwargs(client_id, client_secret, scope) + ) + return _parse_token_response(response) + + +async def fetch_token_async( + auth_url: str, client_id: str, client_secret: str, scope: str +) -> Dict[str, Any]: + async with httpx.AsyncClient() as client: + response = await client.post( + auth_url, **_token_request_kwargs(client_id, client_secret, scope) + ) + return _parse_token_response(response) diff --git a/fragment/client/sync_client.py b/fragment/client/sync_client.py index 41796bf..555f02a 100644 --- a/fragment/client/sync_client.py +++ b/fragment/client/sync_client.py @@ -1,12 +1,10 @@ -# Ignore untyped authlib -# mypy: disable-error-code="import-untyped" import time from typing import Any, Dict, Optional import httpx from ariadne_codegen.client_generators.dependencies.base_client import BaseClient -from authlib.integrations.httpx_client import OAuth2Client +from fragment.client.oauth import fetch_token from fragment.exceptions import MissingArgumentException, MissingTokenException @@ -35,14 +33,18 @@ def __init__( self.auth_url = auth_url self.expiration_time: Optional[float] = None self.token: Optional[Dict[str, Any]] = None - self.oauth2_client = OAuth2Client(client_id, client_secret, scope=auth_scope) + self.client_id = client_id + self.client_secret = client_secret + self.auth_scope = auth_scope def refresh_token(self) -> None: now = time.time() if self.expiration_time is None or self.expiration_time <= now: # Held in a local because `self.token` is declared `dict | None`, # so reading the attribute back is not narrowed by the assignment. - token = self.oauth2_client.fetch_token(self.auth_url) + token = fetch_token( + self.auth_url, self.client_id, self.client_secret, self.auth_scope + ) self.token = token self.expiration_time = now + token["expires_in"] diff --git a/fragment/exceptions.py b/fragment/exceptions.py index ea07b85..1fcd6d1 100644 --- a/fragment/exceptions.py +++ b/fragment/exceptions.py @@ -1,3 +1,6 @@ +from typing import Optional + + class MissingTokenException(ValueError): """Token not found.""" @@ -10,3 +13,12 @@ class MissingArgumentException(ValueError): def __init__(self, argument: str) -> None: super().__init__(f"{argument} must be provided") + + +class TokenRequestException(Exception): + """The auth endpoint did not return an access token.""" + + def __init__(self, error: str, description: Optional[str] = None) -> None: + self.error = error + self.description = description + super().__init__(f"{error}: {description}" if description else error) diff --git a/fragment/sdk/async_client.py b/fragment/sdk/async_client.py index 2d8bb46..1c2b484 100644 --- a/fragment/sdk/async_client.py +++ b/fragment/sdk/async_client.py @@ -1,7 +1,5 @@ # Generated by fragment (with the help of ariadne-codegen) -# Ignore untyped authlib -# mypy: disable-error-code="import-untyped" import time from typing import Any, Dict, Optional @@ -9,8 +7,8 @@ from ariadne_codegen.client_generators.dependencies.async_base_client import ( AsyncBaseClient, ) -from authlib.integrations.httpx_client import AsyncOAuth2Client +from fragment.client.oauth import fetch_token_async from fragment.exceptions import MissingArgumentException, MissingTokenException @@ -39,16 +37,18 @@ def __init__( self.auth_url = auth_url self.expiration_time: Optional[float] = None self.token: Optional[Dict[str, Any]] = None - self.oauth2_client = AsyncOAuth2Client( - client_id, client_secret, scope=auth_scope - ) + self.client_id = client_id + self.client_secret = client_secret + self.auth_scope = auth_scope async def refresh_token(self) -> None: now = time.time() if self.expiration_time is None or self.expiration_time <= now: # Held in a local because `self.token` is declared `dict | None`, # so reading the attribute back is not narrowed by the assignment. - token = await self.oauth2_client.fetch_token(self.auth_url) + token = await fetch_token_async( + self.auth_url, self.client_id, self.client_secret, self.auth_scope + ) self.token = token self.expiration_time = now + token["expires_in"] diff --git a/fragment/sync_sdk/async_client.py b/fragment/sync_sdk/async_client.py index 2d8bb46..1c2b484 100644 --- a/fragment/sync_sdk/async_client.py +++ b/fragment/sync_sdk/async_client.py @@ -1,7 +1,5 @@ # Generated by fragment (with the help of ariadne-codegen) -# Ignore untyped authlib -# mypy: disable-error-code="import-untyped" import time from typing import Any, Dict, Optional @@ -9,8 +7,8 @@ from ariadne_codegen.client_generators.dependencies.async_base_client import ( AsyncBaseClient, ) -from authlib.integrations.httpx_client import AsyncOAuth2Client +from fragment.client.oauth import fetch_token_async from fragment.exceptions import MissingArgumentException, MissingTokenException @@ -39,16 +37,18 @@ def __init__( self.auth_url = auth_url self.expiration_time: Optional[float] = None self.token: Optional[Dict[str, Any]] = None - self.oauth2_client = AsyncOAuth2Client( - client_id, client_secret, scope=auth_scope - ) + self.client_id = client_id + self.client_secret = client_secret + self.auth_scope = auth_scope async def refresh_token(self) -> None: now = time.time() if self.expiration_time is None or self.expiration_time <= now: # Held in a local because `self.token` is declared `dict | None`, # so reading the attribute back is not narrowed by the assignment. - token = await self.oauth2_client.fetch_token(self.auth_url) + token = await fetch_token_async( + self.auth_url, self.client_id, self.client_secret, self.auth_scope + ) self.token = token self.expiration_time = now + token["expires_in"] diff --git a/fragment/sync_sdk/sync_client.py b/fragment/sync_sdk/sync_client.py index 883e546..a036352 100644 --- a/fragment/sync_sdk/sync_client.py +++ b/fragment/sync_sdk/sync_client.py @@ -1,14 +1,12 @@ # Generated by fragment (with the help of ariadne-codegen) -# Ignore untyped authlib -# mypy: disable-error-code="import-untyped" import time from typing import Any, Dict, Optional import httpx from ariadne_codegen.client_generators.dependencies.base_client import BaseClient -from authlib.integrations.httpx_client import OAuth2Client +from fragment.client.oauth import fetch_token from fragment.exceptions import MissingArgumentException, MissingTokenException @@ -37,14 +35,18 @@ def __init__( self.auth_url = auth_url self.expiration_time: Optional[float] = None self.token: Optional[Dict[str, Any]] = None - self.oauth2_client = OAuth2Client(client_id, client_secret, scope=auth_scope) + self.client_id = client_id + self.client_secret = client_secret + self.auth_scope = auth_scope def refresh_token(self) -> None: now = time.time() if self.expiration_time is None or self.expiration_time <= now: # Held in a local because `self.token` is declared `dict | None`, # so reading the attribute back is not narrowed by the assignment. - token = self.oauth2_client.fetch_token(self.auth_url) + token = fetch_token( + self.auth_url, self.client_id, self.client_secret, self.auth_scope + ) self.token = token self.expiration_time = now + token["expires_in"] diff --git a/poetry.lock b/poetry.lock index 9379115..fa3a038 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -96,22 +96,6 @@ docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphi tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] -[[package]] -name = "authlib" -version = "1.7.2" -description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f"}, - {file = "authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231"}, -] - -[package.dependencies] -cryptography = "*" -joserfc = ">=1.6.0" - [[package]] name = "autoflake" version = "2.3.1" @@ -673,24 +657,6 @@ files = [ [package.extras] colors = ["colorama (>=0.4.6)"] -[[package]] -name = "joserfc" -version = "1.7.1" -description = "The ultimate Python library for JOSE RFCs, including JWS, JWE, JWK, JWA, JWT" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "joserfc-1.7.1-py3-none-any.whl", hash = "sha256:b3e3d655612e2e1ef67b2600f2f420e12e537b020208fab1761fad647319c164"}, - {file = "joserfc-1.7.1.tar.gz", hash = "sha256:77d0b76514879c68c6f433bc5b7357a4ab72008ff1e33d8379fd11d72bd8ca81"}, -] - -[package.dependencies] -cryptography = ">=45.0.1" - -[package.extras] -drafts = ["pycryptodome"] - [[package]] name = "jwskate" version = "0.12.2" @@ -1009,7 +975,7 @@ files = [ ] [package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +typing-extensions = ">=4.6.0,!=4.7.0" [[package]] name = "pyflakes" @@ -1055,10 +1021,10 @@ astroid = ">=3.3.8,<=3.4.0.dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.2", markers = "python_version < \"3.11\""}, - {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, {version = ">=0.3.6", markers = "python_version == \"3.11\""}, + {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, ] -isort = ">=4.2.5,<5.13 || >5.13,<7" +isort = ">=4.2.5,!=5.13,<7" mccabe = ">=0.6,<0.8" platformdirs = ">=2.2" tomli = {version = ">=1.1", markers = "python_version < \"3.11\""} @@ -1336,4 +1302,4 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "7d6dba94f94349cb8f8aad79106c49e80584ca4f0a41aedb26534780db23f571" +content-hash = "ea7382fd78cb21da2f03613f415a77f43e3ffec0c3397d205cf4da3b4d7e976d" diff --git a/pyproject.toml b/pyproject.toml index 6ca4ebb..c593f00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,6 @@ ariadne-codegen = "^0.17.2" requests-oauth2client = "^1.6.0" pydantic = "^2.8.0" httpx = "^0.28.1" -authlib = "^1.6.12" graphql-core = "^3.2.3" click = "^8.1.7" diff --git a/tests/snapshots/001-marketing-schema/sdk/async_client.py b/tests/snapshots/001-marketing-schema/sdk/async_client.py index 2d8bb46..1c2b484 100644 --- a/tests/snapshots/001-marketing-schema/sdk/async_client.py +++ b/tests/snapshots/001-marketing-schema/sdk/async_client.py @@ -1,7 +1,5 @@ # Generated by fragment (with the help of ariadne-codegen) -# Ignore untyped authlib -# mypy: disable-error-code="import-untyped" import time from typing import Any, Dict, Optional @@ -9,8 +7,8 @@ from ariadne_codegen.client_generators.dependencies.async_base_client import ( AsyncBaseClient, ) -from authlib.integrations.httpx_client import AsyncOAuth2Client +from fragment.client.oauth import fetch_token_async from fragment.exceptions import MissingArgumentException, MissingTokenException @@ -39,16 +37,18 @@ def __init__( self.auth_url = auth_url self.expiration_time: Optional[float] = None self.token: Optional[Dict[str, Any]] = None - self.oauth2_client = AsyncOAuth2Client( - client_id, client_secret, scope=auth_scope - ) + self.client_id = client_id + self.client_secret = client_secret + self.auth_scope = auth_scope async def refresh_token(self) -> None: now = time.time() if self.expiration_time is None or self.expiration_time <= now: # Held in a local because `self.token` is declared `dict | None`, # so reading the attribute back is not narrowed by the assignment. - token = await self.oauth2_client.fetch_token(self.auth_url) + token = await fetch_token_async( + self.auth_url, self.client_id, self.client_secret, self.auth_scope + ) self.token = token self.expiration_time = now + token["expires_in"] diff --git a/tests/test_oauth.py b/tests/test_oauth.py new file mode 100644 index 0000000..28917d1 --- /dev/null +++ b/tests/test_oauth.py @@ -0,0 +1,144 @@ +"""The client-credentials token request the SDK makes in place of authlib. + +Offline: every request goes to an `httpx.MockTransport`. +""" + +import base64 +import json +from functools import partial +from typing import Any, Callable, List, TypedDict +from urllib.parse import parse_qs + +import httpx +import pytest + +from fragment.client import oauth +from fragment.client.async_client import AsyncFragmentClient +from fragment.client.sync_client import SyncFragmentClient +from fragment.exceptions import TokenRequestException + +AUTH_URL = "https://auth.example.test/oauth2/token" +TOKEN = {"access_token": "tok", "expires_in": 3600, "token_type": "Bearer"} + +Handler = Callable[[httpx.Request], httpx.Response] + + +@pytest.fixture +def requests(monkeypatch: pytest.MonkeyPatch) -> List[httpx.Request]: + """Route token requests to TOKEN and record them.""" + seen: List[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200, json=TOKEN) + + route(monkeypatch, handler) + return seen + + +def route(monkeypatch: pytest.MonkeyPatch, handler: Handler) -> None: + transport = httpx.MockTransport(handler) + sync_client = partial(httpx.Client, transport=transport) + async_client = partial(httpx.AsyncClient, transport=transport) + monkeypatch.setattr("fragment.client.oauth.httpx.Client", sync_client) + monkeypatch.setattr("fragment.client.oauth.httpx.AsyncClient", async_client) + + +def respond(monkeypatch: pytest.MonkeyPatch, status: int, body: Any) -> None: + content = body if isinstance(body, bytes) else json.dumps(body).encode() + route(monkeypatch, lambda _: httpx.Response(status, content=content)) + + +def test_sends_a_client_credentials_grant_with_basic_auth( + requests: List[httpx.Request], +) -> None: + assert oauth.fetch_token(AUTH_URL, "id", "secret", "scope-a") == TOKEN + + (request,) = requests + assert request.method == "POST" + assert str(request.url) == AUTH_URL + expected = base64.b64encode(b"id:secret").decode() + assert request.headers["Authorization"] == f"Basic {expected}" + assert request.headers["Accept"] == "application/json" + assert parse_qs(request.content.decode()) == { + "grant_type": ["client_credentials"], + "scope": ["scope-a"], + } + + +@pytest.mark.asyncio +async def test_async_sends_the_same_request(requests: List[httpx.Request]) -> None: + assert await oauth.fetch_token_async(AUTH_URL, "id", "secret", "s") == TOKEN + (request,) = requests + assert parse_qs(request.content.decode())["grant_type"] == ["client_credentials"] + + +def test_raises_the_oauth_error_from_the_response( + monkeypatch: pytest.MonkeyPatch, +) -> None: + respond( + monkeypatch, + 400, + {"error": "invalid_client", "error_description": "bad secret"}, + ) + with pytest.raises(TokenRequestException) as raised: + oauth.fetch_token(AUTH_URL, "id", "secret", "s") + assert raised.value.error == "invalid_client" + assert raised.value.description == "bad secret" + + +def test_raises_on_a_server_error(monkeypatch: pytest.MonkeyPatch) -> None: + respond(monkeypatch, 503, {}) + with pytest.raises(httpx.HTTPStatusError): + oauth.fetch_token(AUTH_URL, "id", "secret", "s") + + +@pytest.mark.parametrize( + "body", + [b"gateway", [], {"access_token": "tok"}], + ids=["not-json", "not-an-object", "missing-expires-in"], +) +def test_raises_when_the_response_is_not_a_token( + monkeypatch: pytest.MonkeyPatch, body: Any +) -> None: + respond(monkeypatch, 200, body) + with pytest.raises(TokenRequestException): + oauth.fetch_token(AUTH_URL, "id", "secret", "s") + + +class ClientKwargs(TypedDict): + api_url: str + auth_url: str + auth_scope: str + client_id: str + client_secret: str + + +CLIENT_KWARGS: ClientKwargs = { + "api_url": "https://api.example.test/graphql", + "auth_url": AUTH_URL, + "auth_scope": "s", + "client_id": "id", + "client_secret": "secret", +} + + +def test_sync_client_fetches_once_until_expiry( + requests: List[httpx.Request], +) -> None: + client = SyncFragmentClient(**CLIENT_KWARGS) + client.refresh_token() + client.refresh_token() + assert client.token == TOKEN + assert len(requests) == 1 + + +@pytest.mark.asyncio +async def test_async_client_fetches_once_until_expiry( + requests: List[httpx.Request], +) -> None: + client = AsyncFragmentClient(**CLIENT_KWARGS) + await client.refresh_token() + await client.refresh_token() + assert client.token == TOKEN + assert len(requests) == 1 diff --git a/tests/test_packaging.py b/tests/test_packaging.py index ce3eeff..9f5767f 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -53,3 +53,16 @@ def test_the_wheel_ships_both_sdks(wheel: Path) -> None: names = set(archive.namelist()) assert "fragment/sdk/typed_entries.py" in names assert "fragment/sync_sdk/typed_entries.py" in names + + +def test_the_wheel_does_not_depend_on_authlib(wheel: Path) -> None: + """The SDK fetches its token with httpx; authlib was dropped so customers + stop inheriting its JOSE/OIDC advisories. Guards against it creeping back.""" + with zipfile.ZipFile(wheel) as archive: + (metadata,) = [n for n in archive.namelist() if n.endswith("/METADATA")] + requires = [ + line + for line in archive.read(metadata).decode().splitlines() + if line.startswith("Requires-Dist:") + ] + assert not any("authlib" in line.lower() for line in requires), requires