From e73f6bab5b4509713ef4ff749e91472c58eca350 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Tue, 13 Oct 2026 17:51:49 -0700 Subject: [PATCH] Support bounded OAuth connection grants in v26.10.02 --- CHANGELOG.md | 25 ++ README.md | 9 +- docs/cli.md | 2 +- docs/getting-started.md | 2 +- docs/installation.md | 2 +- docs/modules/core.md | 4 +- docs/modules/oauth2-client.md | 65 ++++- docs/versioning.md | 7 +- install.sh | 2 +- pyproject.toml | 2 +- src/pyfly/__init__.py | 2 +- src/pyfly/oauth2/acquisition.py | 112 +++++++- tests/oauth2/test_connection_grants.py | 370 +++++++++++++++++++++++++ uv.lock | 2 +- web/index.html | 2 +- 15 files changed, 575 insertions(+), 33 deletions(-) create mode 100644 tests/oauth2/test_connection_grants.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cc714855..84de97f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.10.02 (2026-10-13) + +### Added + +- Public `OAuth2Client.refresh_token()` for one bounded refresh request with + optional scopes, explicit Basic or POST authentication and a resource + indicator. Missing response scopes and refresh tokens remain absent; + applications retain access-policy validation and rotating-token custody. +- `OAuth2Client.exchange_code()` accepts explicit client authentication and a + resource indicator. The existing POST authentication and default wire format + are preserved. Resources are validated identifiers sent only to the trusted + token endpoint; they are never fetched. + +### Compatibility + +- Code, redirect URI and client credentials now reject controls, invalid UTF-8 + and values exceeding their 4096-byte request bound before HTTP. Public clients + retain POST authentication without a secret; Basic requires confidential + credentials. Resource and refresh-scope limits are documented in the + [standalone OAuth client guide](docs/modules/oauth2-client.md). +- Neither operation retries or changes authentication methods after a failure. + Applications still own callback state and issuer checks, encrypted storage, + refresh serialization and reconciliation of unknown provider outcomes. + Returned ID tokens remain opaque values, not verified identities. + ## v26.10.01 (2026-10-06) ### Added diff --git a/README.md b/README.md index 1dc4273c..cc09e863 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.10.01 + Version: 26.10.02 Type Checked: mypy strict Code Style: Ruff Async First @@ -854,13 +854,13 @@ See **[`samples/lumen/`](samples/lumen/README.md)** for an end-to-end DDD micros ```bash # Install the latest release (uv) -uv add "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.10.1-py3-none-any.whl" +uv add "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.10.2-py3-none-any.whl" # Install with specific extras -uv add "pyfly[web,data-relational,cache] @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.10.1-py3-none-any.whl" +uv add "pyfly[web,data-relational,cache] @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.10.2-py3-none-any.whl" # Or with pip -pip install "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.10.1-py3-none-any.whl" +pip install "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.10.2-py3-none-any.whl" ``` ### One-Line Install (CLI + Framework) @@ -1190,6 +1190,7 @@ The git tag and human-readable display use the leading-zero form (`v26.05.01`); The full release history lives in **[CHANGELOG.md](CHANGELOG.md)** ([Keep a Changelog](https://keepachangelog.com/) format). Recent highlights: +- **`v26.10.02`** (2026-10-13) — bounded OAuth token refresh, explicit code-exchange authentication and resource indicators; applications retain callback verification and token custody. - **`v26.10.01`** (2026-10-06) — feature flags on OpenFeature and flagd, with layered sources, guarded management, HTTP sync, and a shared PyFly/LaraFly contract. - **`v26.09.15`** (2026-09-30) — public JWKS fetch injection and caller budgets, and bounded OAuth2 client-credentials acquisition. - **`v26.09.14`** (2026-09-30) — opt-in S256 PKCE for OAuth device authorization with proof retained per grant and sent automatically during polling. diff --git a/docs/cli.md b/docs/cli.md index e2d9da24..5117a859 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -776,7 +776,7 @@ Missing optional tools are shown with a `-` dash indicator (dimmed), while missi Verifies that PyFly itself is importable and displays the installed version: ``` -✓ pyfly v26.10.01 +✓ pyfly v26.10.02 ``` ### Summary diff --git a/docs/getting-started.md b/docs/getting-started.md index 59485918..53a9e06a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -480,7 +480,7 @@ ______ ___.__._/ ____\ | ___.__. | __// ____| |__| |____/ ____| |__| \/ \/ - PyFly v26.10.01 | Python 3.12.0 + PyFly v26.10.02 | Python 3.12.0 2026-01-15T10:30:00Z [info] starting_application app=my-service version=0.1.0 2026-01-15T10:30:00Z [info] no_active_profiles message=No active profiles set, falling back to default diff --git a/docs/installation.md b/docs/installation.md index a06c54ad..c76bad06 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -513,7 +513,7 @@ PyFly Doctor ✓ mypy — Type checker PyFly packages: - ✓ pyfly v26.10.01 + ✓ pyfly v26.10.02 All checks passed! ``` diff --git a/docs/modules/core.md b/docs/modules/core.md index 9ef133fd..c247471f 100644 --- a/docs/modules/core.md +++ b/docs/modules/core.md @@ -599,7 +599,7 @@ class BannerMode(enum.Enum): | Mode | Behavior | |---|---| | `TEXT` | Full ASCII art banner (default) with a framework version line. | -| `MINIMAL` | Single line: `:: PyFly :: (v26.10.01)` | +| `MINIMAL` | Single line: `:: PyFly :: (v26.10.02)` | | `OFF` | No banner output at all. | ### BannerPrinter Class @@ -640,7 +640,7 @@ ______ ___.__._/ ____\ | ___.__. | __// ____| |__| |____/ ____| |__| \/ \/ -:: PyFly Framework :: (v26.10.01) +:: PyFly Framework :: (v26.10.02) ``` ### Custom Banner Files diff --git a/docs/modules/oauth2-client.md b/docs/modules/oauth2-client.md index 6d723672..34a7b181 100644 --- a/docs/modules/oauth2-client.md +++ b/docs/modules/oauth2-client.md @@ -67,9 +67,62 @@ redirect URI/path and issuer (including mix-up/replay protection). The library creates no callback listener or browser process. `OAuth2Tokens.id_token` is an opaque value, not verified identity; never substitute it for `access_token`. Credential storage, refresh serialization/rotation and local roles remain the -application's responsibility. Authorization-code and device operations use form -authentication for an explicitly supplied confidential-client secret; public -clients send no secret. +application's responsibility. Device operations use form authentication for an +explicitly supplied confidential-client secret; public clients send no secret. + +## Code exchange and refresh + +`exchange_code(code, *, redirect_uri, code_verifier, authentication="client_secret_post", resource=None)` +keeps the existing form authentication and wire format when the new options are +omitted. For providers requiring HTTP Basic, explicitly choose +`authentication="client_secret_basic"`. Basic requires a nonempty confidential +client secret and form-encodes each credential before base64 encoding. The default +post method also supports public clients without a secret. Neither method falls +back to another authentication method after rejection. + +Use `refresh_token(refresh_token, *, scopes=(), authentication="client_secret_post", resource=None)` +for one explicit refresh request: + +```python +async def refresh_saved_grant(saved_refresh_token: str): + async with OAuth2Client("my-public-client", endpoints) as client: + return await client.refresh_token( + saved_refresh_token, + scopes=("api.read",), + resource="https://api.example.com", + ) +``` + +Code, redirect URI and client ID must be nonempty UTF-8 strings of at most +4096 bytes without C0/C1 controls. Nonempty client secrets have the same limits; +omitting the secret or passing an empty secret uses a public client with post +authentication. Refresh tokens have +the same rules with a 65,536-byte limit. Invalid values fail before HTTP with +redacted errors. Refresh scopes use the bounded scope syntax below; an empty +tuple omits the parameter. Returned scopes are checked for syntax, but the caller +must validate them against the original grant and its current access policy. +The library does not infer approved scopes from requested scopes. Missing +`scope` or `refresh_token` fields remain `None`; it does not silently reuse a +previous refresh token or invent a replacement. + +`resource` is an optional [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707#section-2) +identifier sent only to the configured token endpoint. It is never fetched or +opened. Use the exact identifier registered with the trusted provider; the +library does not discover or approve resources. The value must be an absolute +ASCII URI, at most 2048 UTF-8 bytes, with valid percent escapes and no userinfo, +fragment or controls. HTTPS resources require a host. URNs, `api://` identifiers +and other absolute schemes are accepted as identifiers. HTTP resources require +`allow_loopback_http=True` and a literal `127.0.0.1` or `::1` host. This resource +profile is stricter than the development token-endpoint profile: `localhost` +and truthy non-boolean opt-ins are not accepted for HTTP resources. + +Code exchange and refresh each dispatch once using the constructor timeout and +response bound. Cancellation, timeout, malformed responses and transport errors +never trigger a replay. The application must serialize refreshes, encrypt stored +tokens and persist rotation decisions. If a refresh request may have reached the +provider but its result is unknown, do not automatically replay the old token; +reconcile the grant or ask the user to authorize again. These operations create +no callback state, token vault, browser flow, identity verification or local roles. For machine access, use the public client-credentials operation: @@ -113,9 +166,9 @@ Machine acquisition accepts only valid Bearer token responses, including the applies the same scope limits to a returned scope. The presence of either `refresh_token` or `id_token`, even with a null value, fails with `OAuth2ClientError("invalid_response")`. This is a strict machine-token contract; -authorization-code and device token response behavior is unchanged. No refresh -grant, persistence, automatic renewal or retry is performed. Scope and access -policy remain the application's responsibility. +authorization-code and device token response behavior is unchanged. Machine +acquisition performs no refresh grant, persistence, automatic renewal or retry. +Scope and access policy remain the application's responsibility. The per-call `timeout` must be finite and positive; booleans are not accepted. It bounds the request and response read by the smaller of this value and the diff --git a/docs/versioning.md b/docs/versioning.md index f17fa886..3e7be77e 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -64,6 +64,7 @@ rare case where a substantial change needs an additional review window. | Version | Date | Notes | |---------|------|-------| +| `26.10.02` | 2026-10-13 | Bounded token refresh and explicit code-exchange authentication/resource indicators. | | `26.10.01` | 2026-10-06 | Feature flags on OpenFeature and flagd, with a shared PyFly/LaraFly contract. | | `26.09.15` | 2026-09-30 | Public JWKS fetch seam and caller budgets; bounded OAuth2 client credentials. | | `26.09.14` | 2026-09-30 | Opt-in S256 PKCE for device authorization and token polling. | @@ -99,17 +100,17 @@ shipped, with the version metadata updated. ```python import pyfly -print(pyfly.__version__) # → "26.10.01" +print(pyfly.__version__) # → "26.10.02" ``` ```bash -pyfly --version # → 26.10.01 +pyfly --version # → 26.10.02 ``` The startup banner displays the leading-zero form: ``` -:: PyFly Framework :: (v26.10.01) (Python 3.13.9) +:: PyFly Framework :: (v26.10.02) (Python 3.13.9) ``` --- diff --git a/install.sh b/install.sh index 6bde2591..1ffb6447 100755 --- a/install.sh +++ b/install.sh @@ -26,7 +26,7 @@ set -euo pipefail # ── Constants ────────────────────────────────────────────────────────────────── -PYFLY_VERSION="26.10.01" +PYFLY_VERSION="26.10.02" PYFLY_REPO="https://github.com/fireflyframework/fireflyframework-pyfly.git" DEFAULT_INSTALL_DIR="$HOME/.pyfly" MIN_PYTHON_MAJOR=3 diff --git a/pyproject.toml b/pyproject.toml index 7b4b4a0d..67f9847c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pyfly" # CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4); # git tag, GitHub release and human-readable display use leading-zero form # (v26.05.04) to match the Java/.NET/Go siblings. -version = "26.10.1" +version = "26.10.2" description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more." readme = "README.md" license = "Apache-2.0" diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py index 99225afd..369397a7 100644 --- a/src/pyfly/__init__.py +++ b/src/pyfly/__init__.py @@ -13,4 +13,4 @@ # limitations under the License. """PyFly — Enterprise Python Framework.""" -__version__ = "26.10.01" +__version__ = "26.10.02" diff --git a/src/pyfly/oauth2/acquisition.py b/src/pyfly/oauth2/acquisition.py index fe608ce8..2e324fb5 100644 --- a/src/pyfly/oauth2/acquisition.py +++ b/src/pyfly/oauth2/acquisition.py @@ -1,6 +1,6 @@ # Copyright 2026 Firefly Software Foundation. # Licensed under the Apache License, Version 2.0. -"""Bounded authorization-code/PKCE, client-credentials and device acquisition.""" +"""Bounded authorization-code/PKCE, refresh, client-credentials and device acquisition.""" from __future__ import annotations @@ -169,6 +169,48 @@ def _scope_value(scopes: tuple[str, ...]) -> str: return value +def _request_value(value: str, name: str, limit: int = 4096) -> str: + if not isinstance(value, str) or not value or any(ord(c) < 32 or 127 <= ord(c) <= 159 for c in value): + raise ValueError(f"{name} requires a bounded nonempty UTF-8 string without controls") + try: + if len(value.encode("utf-8")) > limit: + raise ValueError + except (UnicodeError, ValueError): + raise ValueError(f"{name} exceeds its UTF-8 request bound") from None + return value + + +def _resource_value(value: str | None, *, allow_loopback_http: bool) -> str | None: + if value is None: + return None + _request_value(value, "resource", 2048) + try: + if not re.fullmatch(r"[A-Za-z0-9._~:/?\[\]@!$&'()*+,;=%-]+", value) or re.search(r"%(?![A-Fa-f0-9]{2})", value): + raise ValueError + parsed = urlsplit(value) + if not parsed.scheme or not (parsed.netloc or parsed.path) or parsed.username is not None: + raise ValueError + if not re.fullmatch(r"[A-Za-z0-9._~!$&'()*+,;=:@%/-]*", parsed.path): + raise ValueError + if not re.fullmatch(r"[A-Za-z0-9._~!$&'()*+,;=:@%/?-]*", parsed.query): + raise ValueError + if parsed.netloc and not re.fullmatch( + r"(?:\[[A-Za-z0-9._~!$&'()*+,;=:%-]+\]|[A-Za-z0-9._~!$&'()*+,;=%-]+)(?::[0-9]*)?", + parsed.netloc, + ): + raise ValueError + if parsed.scheme in {"http", "https"}: + host = parsed.hostname + _ = parsed.port + if not host or not host.strip("."): + raise ValueError + if parsed.scheme == "http" and not (allow_loopback_http is True and host in {"127.0.0.1", "::1"}): + raise ValueError + except ValueError: + raise ValueError("resource requires a safe absolute URI without userinfo or fragments") from None + return value + + class OAuth2Client: """Async acquisition client owning its HTTPX client and supplied transport. @@ -352,26 +394,76 @@ async def client_credentials( raise OAuth2ClientError("invalid_response") from None return tokens - async def exchange_code(self, code: str, *, redirect_uri: str, code_verifier: str) -> OAuth2Tokens: + def _connection_authentication(self, authentication: Literal["client_secret_basic", "client_secret_post"]) -> None: + if authentication not in ("client_secret_basic", "client_secret_post"): + raise ValueError("Unsupported client authentication method") + _request_value(self._client_id, "client_id") + if self._secret is not None and self._secret != "": + _request_value(self._secret, "client_secret") + elif authentication == "client_secret_basic": + raise ValueError("Basic authentication requires confidential client credentials") + + async def exchange_code( + self, + code: str, + *, + redirect_uri: str, + code_verifier: str, + authentication: Literal["client_secret_basic", "client_secret_post"] = "client_secret_post", + resource: str | None = None, + ) -> OAuth2Tokens: """Exchange only after caller verifies callback state/issuer/URI and replay. The redirect URI is sent verbatim; no listener is created. Returned ID tokens are opaque and must not be used as authenticated identity. """ + self._connection_authentication(authentication) + _request_value(code, "code") + _request_value(redirect_uri, "redirect_uri") pkce_challenge(code_verifier) - if not code or not redirect_uri: - raise ValueError("code and redirect_uri are required") + resource = _resource_value(resource, allow_loopback_http=self._loopback) + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "code_verifier": code_verifier, + } + if resource is not None: + data["resource"] = resource doc = await self._post( self._endpoints.token_endpoint, - { - "grant_type": "authorization_code", - "code": code, - "redirect_uri": redirect_uri, - "code_verifier": code_verifier, - }, + data, + authentication=authentication, ) return self._tokens(doc) + async def refresh_token( + self, + refresh_token: str, + *, + scopes: tuple[str, ...] = (), + authentication: Literal["client_secret_basic", "client_secret_post"] = "client_secret_post", + resource: str | None = None, + ) -> OAuth2Tokens: + """Make one refresh request; the caller owns scope policy and rotation/replay custody.""" + self._connection_authentication(authentication) + _request_value(refresh_token, "refresh_token", 65536) + scope = _scope_value(scopes) + resource = _resource_value(resource, allow_loopback_http=self._loopback) + data = {"grant_type": "refresh_token", "refresh_token": refresh_token} + if scope: + data["scope"] = scope + if resource is not None: + data["resource"] = resource + doc = await self._post(self._endpoints.token_endpoint, data, authentication=authentication) + tokens = self._tokens(doc) + if tokens.scope is not None: + try: + _scope_value(tuple(tokens.scope.split(" "))) + except ValueError: + raise OAuth2ClientError("invalid_response") from None + return tokens + async def authorize_device(self, *, scopes: tuple[str, ...] = (), use_pkce: bool = False) -> DeviceAuthorization: """Start a device grant, optionally binding it to an internally generated S256 proof. diff --git a/tests/oauth2/test_connection_grants.py b/tests/oauth2/test_connection_grants.py new file mode 100644 index 00000000..e361a9ea --- /dev/null +++ b/tests/oauth2/test_connection_grants.py @@ -0,0 +1,370 @@ +"""Code and refresh acquisition bind a single explicit wire request, never an identity.""" + +import asyncio +import base64 +from urllib.parse import parse_qs + +import httpx +import pytest + +from pyfly.oauth2 import OAuth2Client, OAuth2ClientError, OAuth2Endpoints + +ENDPOINTS = OAuth2Endpoints("https://identity.test/token") +VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" +TOKENS = { + "access_token": "new-access", + "refresh_token": "new-refresh", + "token_type": "Bearer", + "expires_in": 60, + "scope": "repo.read", + "id_token": "opaque-not-verified-identity", +} + + +async def call(client, operation, **kwargs): + if operation == "code": + return await client.exchange_code( + "code", redirect_uri="https://forge.test/callback", code_verifier=VERIFIER, **kwargs + ) + return await client.refresh_token("old-refresh", **kwargs) + + +@pytest.mark.parametrize("operation", ["code", "refresh"]) +@pytest.mark.parametrize("authentication", ["client_secret_basic", "client_secret_post"]) +async def test_confidential_authentication_and_resource_have_exact_wire_location(operation, authentication): + requests = [] + + def respond(request): + requests.append(request) + return httpx.Response(200, json=TOKENS) + + async with OAuth2Client( + "client :+é", ENDPOINTS, client_secret="secret :+é", transport=httpx.MockTransport(respond) + ) as client: + result = await call(client, operation, authentication=authentication, resource="https://resource.test/mcp") + assert len(requests) == 1 and requests[0].url == ENDPOINTS.token_endpoint + expected = {"resource": ["https://resource.test/mcp"]} + if operation == "code": + expected.update( + grant_type=["authorization_code"], + code=["code"], + redirect_uri=["https://forge.test/callback"], + code_verifier=[VERIFIER], + ) + else: + expected.update(grant_type=["refresh_token"], refresh_token=["old-refresh"]) + if authentication == "client_secret_basic": + assert base64.b64decode(requests[0].headers["authorization"].removeprefix("Basic ")) == ( + b"client+%3A%2B%C3%A9:secret+%3A%2B%C3%A9" + ) + else: + assert "authorization" not in requests[0].headers + expected.update(client_id=["client :+é"], client_secret=["secret :+é"]) + assert parse_qs(requests[0].content.decode()) == expected + assert result.access_token == "new-access" and result.refresh_token == "new-refresh" + assert result.id_token == "opaque-not-verified-identity" + assert "new-access" not in repr(result) and "new-refresh" not in repr(result) + assert "opaque-not-verified" not in repr(result) + + +async def test_legacy_code_request_is_byte_identical_and_has_no_resource(): + requests = [] + + def respond(request): + requests.append(request) + return httpx.Response(200, json=TOKENS) + + async with OAuth2Client("client", ENDPOINTS, transport=httpx.MockTransport(respond)) as client: + await call(client, "code") + assert len(requests) == 1 + assert requests[0].content == ( + b"grant_type=authorization_code&code=code&redirect_uri=https%3A%2F%2Fforge.test%2Fcallback" + b"&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk&client_id=client" + ) + assert "authorization" not in requests[0].headers + + +async def test_public_refresh_default_does_not_invent_scopes_or_rotated_token(): + requests = [] + + def respond(request): + requests.append(request) + return httpx.Response(200, json={"access_token": "new-access", "token_type": "Bearer"}) + + async with OAuth2Client("client", ENDPOINTS, transport=httpx.MockTransport(respond)) as client: + token = await client.refresh_token("old-refresh") + assert len(requests) == 1 + assert requests[0].content == b"grant_type=refresh_token&refresh_token=old-refresh&client_id=client" + assert "authorization" not in requests[0].headers + assert token.scope is None and token.refresh_token is None and token.id_token is None + + +async def test_refresh_sends_only_explicit_scopes_and_returns_provider_scope(): + requests = [] + + def respond(request): + requests.append(request) + return httpx.Response(200, json={"access_token": "access", "token_type": "Bearer", "scope": "repo.read"}) + + async with OAuth2Client("client", ENDPOINTS, transport=httpx.MockTransport(respond)) as client: + result = await client.refresh_token("refresh", scopes=("repo.read", "repo.metadata"), resource="urn:repo:42") + assert parse_qs(requests[0].content.decode()) == { + "grant_type": ["refresh_token"], + "refresh_token": ["refresh"], + "client_id": ["client"], + "scope": ["repo.read repo.metadata"], + "resource": ["urn:repo:42"], + } + assert result.scope == "repo.read" + + +@pytest.mark.parametrize("operation", ["code", "refresh"]) +@pytest.mark.parametrize( + "resource", + [ + "https://api.test/mcp", + "urn:example:resource", + "api://application-id", + "custom:registered-id", + "https://[::1]/api?version=1", + "api://app:1234/resource", + "urn:example:a%5Bb%5D", + "custom:opaque?x=a/b?c", + ], +) +async def test_absolute_resources_are_identifiers_never_fetched(operation, resource): + requests = [] + + def respond(request): + requests.append(request) + return httpx.Response(200, json=TOKENS) + + async with OAuth2Client("client", ENDPOINTS, transport=httpx.MockTransport(respond)) as client: + await call(client, operation, resource=resource) + assert len(requests) == 1 and requests[0].url == ENDPOINTS.token_endpoint + assert parse_qs(requests[0].content.decode())["resource"] == [resource] + + +@pytest.mark.parametrize("operation", ["code", "refresh"]) +@pytest.mark.parametrize( + "resource", + [ + "", + "relative/path", + "//api.test/path", + "urn:", + "api://", + "https:opaque", + "urn:example:a[b]", + "urn:example:a?query[bad]", + "api://application:invalid", + "https://api.test/a[b]", + "https:///path", + "https://user:private-sentinel@api.test/mcp", + "urn:example:a#", + "urn:example:a#fragment", + "https://api.test/mcp\nprivate-sentinel", + "https://api.test/\x7f", + "urn:example:\x85", + "urn:example:a b", + "https://api.test:bad/mcp", + "https://api.test:65536/mcp", + "https://api.test/%zz", + "urn:example:\ud800", + "urn:example:", + "https://api.test/back\\slash", + "http://api.test/mcp", + "http://127.0.0.1:1234/mcp", + "urn:example:" + "x" * 2048, + False, + 42, + ], +) +async def test_invalid_resource_fails_before_http_without_reflection(operation, resource): + def respond(request): + pytest.fail("Invalid resource must not dispatch any request") + + async with OAuth2Client("client", ENDPOINTS, transport=httpx.MockTransport(respond)) as client: + with pytest.raises(ValueError) as caught: + await call(client, operation, resource=resource) + assert "private-sentinel" not in str(caught.value) + + +async def test_resource_exact_byte_limit_and_explicit_literal_loopback(): + requests = [] + + def respond(request): + requests.append(request) + return httpx.Response(200, json=TOKENS) + + async with OAuth2Client( + "client", ENDPOINTS, allow_loopback_http=True, transport=httpx.MockTransport(respond) + ) as client: + await client.refresh_token("refresh", resource="urn:example:" + "x" * 2036) + await client.refresh_token("refresh", resource="http://127.0.0.1:1234/mcp") + await client.refresh_token("refresh", resource="http://[::1]:1234/mcp") + with pytest.raises(ValueError): + await client.refresh_token("refresh", resource="http://localhost:1234/mcp") + assert len(requests) == 3 + assert len(parse_qs(requests[0].content.decode())["resource"][0].encode()) == 2048 + + +@pytest.mark.parametrize("opt_in", ["true", 1]) +async def test_http_resource_requires_explicit_boolean_opt_in(opt_in): + async with OAuth2Client( + "client", + ENDPOINTS, + allow_loopback_http=opt_in, + transport=httpx.MockTransport(lambda _: pytest.fail("No request expected")), + ) as client: + with pytest.raises(ValueError): + await client.refresh_token("refresh", resource="http://127.0.0.1:1234/mcp") + + +@pytest.mark.parametrize("operation", ["code", "refresh"]) +@pytest.mark.parametrize( + "client_id,secret,authentication", + [ + (42, None, "client_secret_post"), + ("client", 42, "client_secret_post"), + ("client\nprivate-sentinel", "secret", "client_secret_post"), + ("client", "secret\x85private-sentinel", "client_secret_post"), + ("x" * 4097, None, "client_secret_post"), + ("client", "é" * 2049, "client_secret_post"), + ("client", "\ud800", "client_secret_post"), + ("client", None, "client_secret_basic"), + ("client", "", "client_secret_basic"), + ("client", "secret", "none"), + ("client", "secret", None), + ("client", "secret", []), + ("client", "secret", {}), + ], +) +async def test_invalid_credentials_or_auth_method_fail_before_http(operation, client_id, secret, authentication): + def respond(request): + pytest.fail("Invalid credentials must not reach HTTP") + + async with OAuth2Client( + client_id, ENDPOINTS, client_secret=secret, transport=httpx.MockTransport(respond) + ) as client: + with pytest.raises(ValueError) as caught: + await call(client, operation, authentication=authentication) + assert "private-sentinel" not in str(caught.value) + + +@pytest.mark.parametrize("value", [None, False, 42, "", "x\nprivate-sentinel", "\ud800", "x" * 65537]) +async def test_refresh_token_is_bounded_before_http(value): + async with OAuth2Client( + "client", ENDPOINTS, transport=httpx.MockTransport(lambda _: pytest.fail("No request expected")) + ) as client: + with pytest.raises(ValueError) as caught: + await client.refresh_token(value) + assert "private-sentinel" not in str(caught.value) + + +@pytest.mark.parametrize("value", [None, False, 42, "", "x\nprivate-sentinel", "\ud800", "x" * 4097]) +async def test_code_is_bounded_before_http(value): + async with OAuth2Client( + "client", ENDPOINTS, transport=httpx.MockTransport(lambda _: pytest.fail("No request expected")) + ) as client: + with pytest.raises(ValueError): + await client.exchange_code(value, redirect_uri="https://forge.test/cb", code_verifier=VERIFIER) + + +@pytest.mark.parametrize("scopes", ["read", ["read"], ("",), ("bad scope",), ("é",), ("x" * 257,), ("a",) * 65]) +async def test_refresh_scope_shape_fails_before_http(scopes): + async with OAuth2Client( + "client", ENDPOINTS, transport=httpx.MockTransport(lambda _: pytest.fail("No request expected")) + ) as client: + with pytest.raises(ValueError): + await client.refresh_token("refresh", scopes=scopes) + + +@pytest.mark.parametrize("operation", ["code", "refresh"]) +@pytest.mark.parametrize("outcome", ["redirect", "malformed", "compressed", "protocol", "transport", "timeout"]) +async def test_failures_never_retry_or_expose_credentials(operation, outcome): + requests = [] + + def respond(request): + requests.append(request) + if outcome == "redirect": + return httpx.Response(307, headers={"location": "https://evil.test/private-sentinel"}) + if outcome == "protocol": + return httpx.Response(400, json={"error": "invalid_grant", "error_description": "private-sentinel"}) + if outcome == "transport": + raise httpx.ConnectError("private-sentinel") + if outcome == "timeout": + raise httpx.ReadTimeout("private-sentinel") + if outcome == "compressed": + return httpx.Response( + 200, stream=httpx.ByteStream(b"private-sentinel"), headers={"content-encoding": "gzip"} + ) + return httpx.Response(200, content=b"private-sentinel") + + async with OAuth2Client("client", ENDPOINTS, transport=httpx.MockTransport(respond)) as client: + with pytest.raises(OAuth2ClientError) as caught: + await call(client, operation, resource="https://resource.test/mcp") + assert len(requests) == 1 + assert "private-sentinel" not in str(caught.value) + assert caught.value.code == {"protocol": "invalid_grant", "transport": "transport_error", "timeout": "timeout"}.get( + outcome, "invalid_response" + ) + + +@pytest.mark.parametrize("operation", ["code", "refresh"]) +@pytest.mark.parametrize("cancel", [False, True]) +async def test_response_bounds_and_cancellation_close_without_replay(operation, cancel): + entered = asyncio.Event() + requests = [] + + class Stream(httpx.AsyncByteStream): + closed = False + reads = 0 + + async def __aiter__(self): + self.reads += 1 + yield b"x" * 16 + entered.set() + if cancel: + await asyncio.Event().wait() + self.reads += 1 + yield b"x" * 17 + pytest.fail("Payload cap must stop before another chunk") + + async def aclose(self): + self.closed = True + + stream = Stream() + + def respond(request): + requests.append(request) + return httpx.Response(200, stream=stream) + + async with OAuth2Client( + "client", ENDPOINTS, transport=httpx.MockTransport(respond), max_response_bytes=32 + ) as client: + task = asyncio.create_task(call(client, operation, resource="https://resource.test/mcp")) + if cancel: + waiting = asyncio.create_task(entered.wait()) + await asyncio.wait({waiting, task}, return_when=asyncio.FIRST_COMPLETED) + if task.done(): + waiting.cancel() + await task + else: + await waiting + task.cancel() + with pytest.raises(asyncio.CancelledError if cancel else OAuth2ClientError): + await task + assert len(requests) == 1 and stream.closed + assert stream.reads == (1 if cancel else 2) + + +@pytest.mark.parametrize( + "changes", [{"token_type": "MAC"}, {"expires_in": True}, {"refresh_token": 42}, {"scope": "bad scope"}] +) +async def test_refresh_rejects_malformed_token_fields(changes): + async with OAuth2Client( + "client", ENDPOINTS, transport=httpx.MockTransport(lambda _: httpx.Response(200, json={**TOKENS, **changes})) + ) as client: + with pytest.raises(OAuth2ClientError, match="^invalid_response$"): + await client.refresh_token("refresh") diff --git a/uv.lock b/uv.lock index 40c276de..2fe42089 100644 --- a/uv.lock +++ b/uv.lock @@ -2514,7 +2514,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.10.1" +version = "26.10.2" source = { editable = "." } dependencies = [ { name = "pydantic" }, diff --git a/web/index.html b/web/index.html index a9a73dbe..1014f571 100644 --- a/web/index.html +++ b/web/index.html @@ -90,7 +90,7 @@

Apache 2.0 mypy strict async-first - v26.10.01 + v26.10.02