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 @@
-
+
@@ -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