diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae9c7fad..c0522d9f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
---
+## v26.09.14 (2026-09-30)
+
+### Added
+
+- `OAuth2Client.authorize_device(..., use_pkce=True)` supports providers that
+ require S256 PKCE for device authorization. PyFly generates the proof, sends
+ its challenge and method, and retains the verifier privately on the returned
+ client-owned grant. `poll_device_token(grant)` sends the matching verifier on
+ every poll without exposing it in the grant representation.
+
+### Compatibility
+
+- Device PKCE is explicitly opt-in; the default device authorization and token
+ forms are unchanged. Existing ownership, deadline, cumulative slow-down,
+ timeout backoff, cancellation and resource cleanup behavior is preserved.
+- No password grant, plain challenge method or automatic downgrade is added.
+ Keep grants opaque and do not log or serialize their secret fields.
+
## v26.09.13 (2026-09-30)
### Fixed
diff --git a/README.md b/README.md
index ce34634c..5b9555ed 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-
+
@@ -850,13 +850,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.9.13-py3-none-any.whl"
+uv add "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.9.14-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.9.13-py3-none-any.whl"
+uv add "pyfly[web,data-relational,cache] @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.9.14-py3-none-any.whl"
# Or with pip
-pip install "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.9.13-py3-none-any.whl"
+pip install "pyfly @ https://github.com/fireflyframework/fireflyframework-pyfly/releases/latest/download/pyfly-26.9.14-py3-none-any.whl"
```
### One-Line Install (CLI + Framework)
@@ -1184,6 +1184,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.09.14`** (2026-09-30) — opt-in S256 PKCE for OAuth device authorization with proof retained per grant and sent automatically during polling.
- **`v26.09.13`** (2026-09-30) — deterministic OpenAPI component references for constrained named aliases across independent processes.
- **`v26.09.12`** (2026-09-30) — explicit offline OpenAPI contracts, rich Pydantic schemas, response/security overrides and stable operation IDs without changing handler behavior.
- **`v26.09.11`** (2026-09-30) — scheduled-method discovery skips custom descriptors, avoiding Pydantic instance-field deprecation warnings.
diff --git a/docs/cli.md b/docs/cli.md
index 7181099c..b4c3f1c2 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -775,7 +775,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.09.13
+✓ pyfly v26.09.14
```
### Summary
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 9b47428e..fa96c61d 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -480,7 +480,7 @@ ______ ___.__._/ ____\ | ___.__.
| __// ____| |__| |____/ ____|
|__| \/ \/
- PyFly v26.09.13 | Python 3.12.0
+ PyFly v26.09.14 | 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 a071fc8f..34e02321 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -513,7 +513,7 @@ PyFly Doctor
✓ mypy — Type checker
PyFly packages:
- ✓ pyfly v26.09.13
+ ✓ pyfly v26.09.14
All checks passed!
```
diff --git a/docs/modules/core.md b/docs/modules/core.md
index b2856852..fb683cb6 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.09.13)` |
+| `MINIMAL` | Single line: `:: PyFly :: (v26.09.14)` |
| `OFF` | No banner output at all. |
### BannerPrinter Class
@@ -640,7 +640,7 @@ ______ ___.__._/ ____\ | ___.__.
| __// ____| |__| |____/ ____|
|__| \/ \/
-:: PyFly Framework :: (v26.09.13)
+:: PyFly Framework :: (v26.09.14)
```
### Custom Banner Files
diff --git a/docs/modules/oauth2-client.md b/docs/modules/oauth2-client.md
index 9de6ad21..5f10ea0a 100644
--- a/docs/modules/oauth2-client.md
+++ b/docs/modules/oauth2-client.md
@@ -34,7 +34,33 @@ proof = generate_pkce()
returned tokens and device grants omit secret fields from `repr`. Token responses
are never logged. `OAuth2ClientError.code` is an allowlisted protocol/error code;
provider descriptions, bodies and transport exception strings are not exposed.
-Do not log or serialize token objects yourself.
+Treat PKCE pairs, device grants and token objects as opaque in-memory values. Do
+not log them or serialize them with `dataclasses.asdict()` or another serializer:
+`repr` redaction does not remove secrets from their fields.
+
+For a provider that supports or requires PKCE on device authorization, opt in per
+request:
+
+```python
+async def acquire_with_device_pkce():
+ async with OAuth2Client("my-public-client", endpoints) as client:
+ grant = await client.authorize_device(scopes=("openid", "api"), use_pkce=True)
+ # Display only grant.verification_uri and grant.user_code.
+ return await client.poll_device_token(grant)
+```
+
+`authorize_device(*, scopes=(), use_pkce=False)` keeps its existing wire format by
+default; not every device provider supports this extension. With `use_pkce=True`,
+the client generates a fresh S256 pair internally and sends `code_challenge` and
+`code_challenge_method=S256` to the device endpoint. It never sends the verifier
+there. The returned grant privately retains its verifier; every subsequent poll
+for that grant automatically sends the same `code_verifier` to the token endpoint.
+Concurrent grants have independent verifiers and can be polled in any order.
+`use_pkce` must be a boolean; truthy strings and integers are rejected before HTTP.
+There is no `plain` mode or fallback to an unprotected request after rejection.
+Keep the grant with its originating client until completion, cancellation or expiry;
+do not extract, persist or separately pass its private verifier. The S256 pair does
+not change the existing polling intervals, ownership check, deadline or cleanup.
Before `exchange_code`, the caller must validate a one-time callback state,
redirect URI/path and issuer (including mix-up/replay protection). The library
diff --git a/docs/versioning.md b/docs/versioning.md
index ba999a9d..382bdb80 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.09.14` | 2026-09-30 | Opt-in S256 PKCE for device authorization and token polling. |
| `26.09.13` | 2026-09-30 | Deterministic OpenAPI references for constrained named aliases across processes. |
| `26.09.12` | 2026-09-30 | Offline OpenAPI contracts, rich schema inference and explicit operation overrides. |
| `26.09.11` | 2026-09-30 | Scheduled-method discovery avoids evaluating Pydantic and custom descriptors. |
@@ -96,17 +97,17 @@ shipped, with the version metadata updated.
```python
import pyfly
-print(pyfly.__version__) # → "26.09.13"
+print(pyfly.__version__) # → "26.09.14"
```
```bash
-pyfly --version # → 26.09.13
+pyfly --version # → 26.09.14
```
The startup banner displays the leading-zero form:
```
-:: PyFly Framework :: (v26.09.13) (Python 3.13.9)
+:: PyFly Framework :: (v26.09.14) (Python 3.13.9)
```
---
diff --git a/install.sh b/install.sh
index 9d1070a8..6eab3120 100755
--- a/install.sh
+++ b/install.sh
@@ -26,7 +26,7 @@ set -euo pipefail
# ── Constants ──────────────────────────────────────────────────────────────────
-PYFLY_VERSION="26.09.13"
+PYFLY_VERSION="26.09.14"
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 343e4fb0..9408b4ee 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.9.13"
+version = "26.9.14"
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 8e08dc8f..cd11bebc 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.09.13"
+__version__ = "26.09.14"
diff --git a/src/pyfly/oauth2/acquisition.py b/src/pyfly/oauth2/acquisition.py
index 11706b1d..02431809 100644
--- a/src/pyfly/oauth2/acquisition.py
+++ b/src/pyfly/oauth2/acquisition.py
@@ -87,6 +87,8 @@ class OAuth2Tokens:
@dataclass(frozen=True)
class DeviceAuthorization:
+ """Opaque pending transaction; retain in memory and do not log or serialize it."""
+
device_code: str = field(repr=False)
user_code: str
verification_uri: str
@@ -94,6 +96,7 @@ class DeviceAuthorization:
interval: float
verification_uri_complete: str | None = field(default=None, repr=False)
_owner: object = field(default=None, repr=False, compare=False)
+ _code_verifier: str | None = field(default=None, repr=False, compare=False)
def _endpoint(url: str, allow_loopback_http: bool) -> None:
@@ -297,12 +300,23 @@ async def exchange_code(self, code: str, *, redirect_uri: str, code_verifier: st
)
return self._tokens(doc)
- async def authorize_device(self, *, scopes: tuple[str, ...] = ()) -> DeviceAuthorization:
+ 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.
+
+ Enable PKCE only for providers supporting it on their device endpoint. The
+ verifier stays with this grant and is reused by polling; no fallback occurs.
+ """
+ if not isinstance(use_pkce, bool):
+ raise TypeError("use_pkce must be a bool")
endpoint = self._endpoints.device_authorization_endpoint
if endpoint is None:
raise ValueError("No device authorization endpoint configured")
started = self._clock()
- doc = await self._post(endpoint, {"scope": " ".join(scopes)} if scopes else {})
+ proof = generate_pkce() if use_pkce else None
+ data = {"scope": " ".join(scopes)} if scopes else {}
+ if proof is not None:
+ data.update(code_challenge=proof.challenge, code_challenge_method="S256")
+ doc = await self._post(endpoint, data)
verification_uri = _text(doc, "verification_uri")
complete = _text(doc, "verification_uri_complete") if "verification_uri_complete" in doc else None
try:
@@ -319,12 +333,19 @@ async def authorize_device(self, *, scopes: tuple[str, ...] = ()) -> DeviceAutho
expires_at=started + _positive(doc.get("expires_in")),
interval=_positive(doc.get("interval", 5)),
_owner=self._owner,
+ _code_verifier=proof.verifier if proof is not None else None,
)
async def poll_device_token(self, grant: DeviceAuthorization) -> OAuth2Tokens:
"""Poll with cumulative slow_down, timeout backoff and a monotonic deadline."""
if grant._owner is not self._owner:
raise ValueError("Device grant belongs to another OAuth client")
+ data = {
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
+ "device_code": grant.device_code,
+ }
+ if grant._code_verifier is not None:
+ data["code_verifier"] = grant._code_verifier
interval = grant.interval
while True:
remaining = grant.expires_at - self._clock()
@@ -337,10 +358,7 @@ async def poll_device_token(self, grant: DeviceAuthorization) -> OAuth2Tokens:
try:
doc = await self._post(
self._endpoints.token_endpoint,
- {
- "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
- "device_code": grant.device_code,
- },
+ data,
timeout=remaining,
)
if self._clock() >= grant.expires_at:
diff --git a/tests/oauth2/test_acquisition.py b/tests/oauth2/test_acquisition.py
index dd9bc1e3..0aa9ec29 100644
--- a/tests/oauth2/test_acquisition.py
+++ b/tests/oauth2/test_acquisition.py
@@ -71,7 +71,8 @@ async def sleep(self, seconds):
self.now += seconds
-async def test_device_slow_down_is_cumulative():
+@pytest.mark.parametrize("use_pkce", [False, True])
+async def test_device_slow_down_is_cumulative(use_pkce):
client_type, _, endpoints_type, _, _ = api()
clock = Clock()
responses = iter(
@@ -106,14 +107,15 @@ def respond(request):
clock=clock,
sleep=clock.sleep,
) as client:
- grant = await client.authorize_device()
+ grant = await client.authorize_device(use_pkce=use_pkce)
assert "secret-device" not in repr(grant)
token = await client.poll_device_token(grant)
assert token.access_token == "ok"
assert clock.waits == [5, 5, 10, 15]
-async def test_device_expiry_prevents_poll_and_cancellation_propagates():
+@pytest.mark.parametrize("use_pkce", [False, True])
+async def test_device_expiry_prevents_poll_and_cancellation_propagates(use_pkce):
client_type, error_type, endpoints_type, _, _ = api()
clock = Clock()
calls = []
@@ -139,7 +141,7 @@ def respond(request):
clock=clock,
sleep=clock.sleep,
) as client:
- grant = await client.authorize_device()
+ grant = await client.authorize_device(use_pkce=use_pkce)
with pytest.raises(error_type, match="expired_token"):
await client.poll_device_token(grant)
assert calls == ["/device"]
@@ -189,7 +191,8 @@ def respond(request):
assert "secret" not in str(error.value)
-async def test_cancel_pending_device_sleep():
+@pytest.mark.parametrize("use_pkce", [False, True])
+async def test_cancel_pending_device_sleep(use_pkce):
client_type, _, endpoints_type, _, _ = api()
entered = asyncio.Event()
@@ -217,7 +220,7 @@ def respond(request):
transport=httpx.MockTransport(respond),
sleep=sleep,
) as client:
- grant = await client.authorize_device()
+ grant = await client.authorize_device(use_pkce=use_pkce)
task = asyncio.create_task(client.poll_device_token(grant))
await entered.wait()
task.cancel()
@@ -226,7 +229,8 @@ def respond(request):
@pytest.mark.parametrize("outcome", ["access_denied", "expired_token", "invalid_grant"])
-async def test_device_terminal_errors_do_not_retry(outcome):
+@pytest.mark.parametrize("use_pkce", [False, True])
+async def test_device_terminal_errors_do_not_retry(outcome, use_pkce):
client_type, error_type, endpoints_type, _, _ = api()
clock = Clock()
calls = []
@@ -247,13 +251,14 @@ def respond(request):
clock=clock,
sleep=clock.sleep,
) as client:
- grant = await client.authorize_device()
+ grant = await client.authorize_device(use_pkce=use_pkce)
with pytest.raises(error_type, match=outcome):
await client.poll_device_token(grant)
assert calls == ["/device", "/token"]
-async def test_device_timeout_backoff_and_late_response_expiry():
+@pytest.mark.parametrize("use_pkce", [False, True])
+async def test_device_timeout_backoff_and_late_response_expiry(use_pkce):
client_type, error_type, endpoints_type, _, _ = api()
clock = Clock()
polls = 0
@@ -278,7 +283,7 @@ def respond(request):
clock=clock,
sleep=clock.sleep,
) as client:
- grant = await client.authorize_device()
+ grant = await client.authorize_device(use_pkce=use_pkce)
with pytest.raises(error_type, match="expired_token"):
await client.poll_device_token(grant)
assert clock.waits == [5, 10]
diff --git a/tests/oauth2/test_device_pkce.py b/tests/oauth2/test_device_pkce.py
new file mode 100644
index 00000000..075f19b0
--- /dev/null
+++ b/tests/oauth2/test_device_pkce.py
@@ -0,0 +1,287 @@
+"""Device PKCE stays bound to a single grant through the real HTTP client path."""
+
+import asyncio
+import base64
+import hashlib
+import traceback
+from dataclasses import replace
+from urllib.parse import parse_qs
+
+import httpx
+import pytest
+
+from tests.oauth2.test_acquisition import Clock, api, response
+
+
+class DeviceProvider:
+ """Protocol peer that binds a challenge to each device code and checks S256."""
+
+ def __init__(self, *, supports_pkce=True, outcomes=("authorization_pending", None)):
+ self.supports_pkce = supports_pkce
+ self.outcomes = outcomes
+ self.challenges = {}
+ self.verifiers = {}
+ self.device_forms = []
+ self.token_forms = []
+
+ def __call__(self, request):
+ form = {key: values[0] for key, values in parse_qs(request.content.decode()).items()}
+ if request.url.path == "/device":
+ self.device_forms.append(form)
+ assert "code_verifier" not in form
+ if self.supports_pkce:
+ if form.get("code_challenge_method") != "S256" or not form.get("code_challenge"):
+ return response(400, json={"error": "invalid_request", "error_description": "Missing challenge"})
+ elif "code_challenge" in form or "code_challenge_method" in form:
+ return response(400, json={"error": "invalid_request"})
+ code = f"device-{len(self.device_forms)}"
+ self.challenges[code] = form.get("code_challenge")
+ return response(
+ 200,
+ json={
+ "device_code": code,
+ "user_code": "ABCD",
+ "verification_uri": "https://idp.test/verify",
+ "expires_in": 300,
+ "interval": 1,
+ },
+ )
+ assert request.url.path == "/token"
+ self.token_forms.append(form)
+ code = form["device_code"]
+ assert form["grant_type"] == "urn:ietf:params:oauth:grant-type:device_code"
+ assert "code_challenge" not in form
+ assert "code_challenge_method" not in form
+ if self.supports_pkce:
+ verifier = form.get("code_verifier", "")
+ digest = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
+ if digest != self.challenges[code]:
+ return response(400, json={"error": "invalid_grant"})
+ if code in self.verifiers:
+ assert verifier == self.verifiers[code]
+ self.verifiers[code] = verifier
+ else:
+ assert "code_verifier" not in form
+ poll = sum(item["device_code"] == code for item in self.token_forms)
+ outcome = self.outcomes[min(poll - 1, len(self.outcomes) - 1)]
+ if outcome == "timeout":
+ raise httpx.ReadTimeout("provider request timeout")
+ if outcome is not None:
+ return response(400, json={"error": outcome})
+ return response(200, json={"access_token": f"token-{code}", "token_type": "Bearer"})
+
+
+def client_for(provider, *, clock=None):
+ client_type, _, endpoints_type, _, _ = api()
+ clock = clock or Clock()
+ return client_type(
+ "cli",
+ endpoints_type("https://idp.test/token", "https://idp.test/device"),
+ transport=httpx.MockTransport(provider),
+ clock=clock,
+ sleep=clock.sleep,
+ )
+
+
+async def test_device_pkce_pending_then_success_matches_challenge_without_revealing_verifier(caplog):
+ caplog.set_level("DEBUG")
+ provider = DeviceProvider()
+ async with client_for(provider) as client:
+ grant = await client.authorize_device(scopes=("openid", "api"), use_pkce=True)
+ token = await client.poll_device_token(grant)
+ assert token.access_token == "token-device-1"
+ assert provider.device_forms[0]["scope"] == "openid api"
+ assert provider.device_forms[0]["client_id"] == "cli"
+ verifier = provider.verifiers["device-1"]
+ assert len(provider.token_forms) == 2
+ assert 43 <= len(verifier) <= 128
+ assert verifier not in repr(grant)
+ assert verifier not in repr(token)
+ assert verifier not in caplog.text
+ assert grant == replace(grant, _code_verifier="different-private-value")
+
+
+async def test_two_device_pkce_grants_poll_in_reverse_order_without_crossing_verifiers():
+ provider = DeviceProvider()
+ async with client_for(provider) as client:
+ first, second = await asyncio.gather(
+ client.authorize_device(use_pkce=True), client.authorize_device(use_pkce=True)
+ )
+ second_token, first_token = await asyncio.gather(
+ client.poll_device_token(second), client.poll_device_token(first)
+ )
+ assert first_token.access_token == f"token-{first.device_code}"
+ assert second_token.access_token == f"token-{second.device_code}"
+ assert len(set(provider.challenges.values())) == 2
+ assert len(set(provider.verifiers.values())) == 2
+ assert len(provider.token_forms) == 4
+
+
+@pytest.mark.parametrize("explicit", [False, True])
+async def test_default_device_flow_is_unchanged_for_provider_without_pkce(explicit):
+ provider = DeviceProvider(supports_pkce=False)
+ async with client_for(provider) as client:
+ grant = await client.authorize_device(**({"use_pkce": False} if explicit else {}))
+ token = await client.poll_device_token(grant)
+ assert token.access_token == "token-device-1"
+ assert provider.device_forms == [{"client_id": "cli"}]
+ assert (
+ provider.token_forms
+ == [
+ {
+ "client_id": "cli",
+ "device_code": "device-1",
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
+ }
+ ]
+ * 2
+ )
+
+
+async def test_pkce_rejection_does_not_fall_back_to_unprotected_device_authorization():
+ _, error_type, _, _, _ = api()
+ provider = DeviceProvider(supports_pkce=False)
+ async with client_for(provider) as client:
+ with pytest.raises(error_type, match="invalid_request"):
+ await client.authorize_device(use_pkce=True)
+ assert len(provider.device_forms) == 1
+ assert provider.device_forms[0]["code_challenge_method"] == "S256"
+ assert provider.token_forms == []
+
+
+async def test_device_pkce_grant_wrong_owner_fails_before_sleep_or_http():
+ provider = DeviceProvider()
+ other_calls = []
+ other_clock = Clock()
+ async with (
+ client_for(provider) as owner,
+ client_for(lambda request: other_calls.append(request), clock=other_clock) as other,
+ ):
+ grant = await owner.authorize_device(use_pkce=True)
+ with pytest.raises(ValueError, match="belongs to another"):
+ await other.poll_device_token(grant)
+ assert other_calls == []
+ assert other_clock.waits == []
+ assert provider.token_forms == []
+
+
+@pytest.mark.parametrize("reflection", ["error", "description", "transport"])
+async def test_device_pkce_reflected_verifier_is_redacted(reflection, caplog):
+ caplog.set_level("DEBUG")
+ _, error_type, _, _, _ = api()
+ provider = DeviceProvider()
+ captured = []
+
+ def reflect(request):
+ if request.url.path == "/device":
+ return provider(request)
+ verifier = parse_qs(request.content.decode())["code_verifier"][0]
+ captured.append(verifier)
+ if reflection == "transport":
+ raise httpx.ConnectError(verifier, request=request)
+ return response(
+ 400,
+ json={
+ "error": verifier if reflection == "error" else "invalid_grant",
+ "error_description": verifier,
+ },
+ )
+
+ async with client_for(reflect) as client:
+ grant = await client.authorize_device(use_pkce=True)
+ with pytest.raises(error_type) as caught:
+ await client.poll_device_token(grant)
+ assert len(captured) == 1
+ displayed = str(caught.value) + repr(caught.value) + "".join(traceback.format_exception(caught.value)) + caplog.text
+ assert captured[0] not in displayed
+
+
+@pytest.mark.parametrize("value", [None, 0, 1, "false", "true", []])
+async def test_device_pkce_requires_an_explicit_boolean_before_http(value):
+ calls = []
+ async with client_for(lambda request: calls.append(request)) as client:
+ with pytest.raises(TypeError, match="use_pkce must be a bool"):
+ await client.authorize_device(use_pkce=value)
+ assert calls == []
+
+
+def test_device_authorization_preserves_existing_positional_arguments():
+ from pyfly.oauth2 import DeviceAuthorization
+
+ owner = object()
+ grant = DeviceAuthorization("device", "ABCD", "https://idp.test/v", 123, 5, "https://idp.test/v?code=ABCD", owner)
+ assert grant._owner is owner
+ assert grant.verification_uri_complete == "https://idp.test/v?code=ABCD"
+ assert grant._code_verifier is None
+
+
+async def test_device_pkce_keeps_verifier_through_pending_slowdown_and_timeout_backoff():
+ provider = DeviceProvider(outcomes=("authorization_pending", "slow_down", "timeout", None))
+ clock = Clock()
+ async with client_for(provider, clock=clock) as client:
+ grant = await client.authorize_device(use_pkce=True)
+ token = await client.poll_device_token(grant)
+ assert token.access_token == "token-device-1"
+ assert clock.waits == [1, 1, 6, 12]
+ assert len({form["code_verifier"] for form in provider.token_forms}) == 1
+ assert len(provider.token_forms) == 4
+
+
+@pytest.mark.parametrize("during_close", [False, True])
+async def test_cancel_pkce_poll_closes_inflight_response_and_owned_transport(during_close):
+ provider = DeviceProvider(outcomes=(None,))
+ entered = asyncio.Event()
+ release = asyncio.Event()
+
+ class Stream(httpx.AsyncByteStream):
+ closed = False
+
+ async def __aiter__(self):
+ if not during_close:
+ entered.set()
+ await release.wait()
+ yield b'{"access_token":"token","token_type":"Bearer"}'
+
+ async def aclose(self):
+ if during_close:
+ entered.set()
+ await release.wait()
+ await asyncio.sleep(0)
+ self.closed = True
+
+ stream = Stream()
+
+ def respond(request):
+ result = provider(request)
+ if request.url.path == "/device":
+ return result
+ assert result.status_code == 200
+ return httpx.Response(200, stream=stream)
+
+ class Transport(httpx.MockTransport):
+ closed = False
+
+ async def aclose(self):
+ await super().aclose()
+ self.closed = True
+
+ client_type, _, endpoints_type, _, _ = api()
+ transport = Transport(respond)
+ clock = Clock()
+ async with client_type(
+ "cli",
+ endpoints_type("https://idp.test/token", "https://idp.test/device"),
+ transport=transport,
+ clock=clock,
+ sleep=clock.sleep,
+ ) as client:
+ grant = await client.authorize_device(use_pkce=True)
+ task = asyncio.create_task(client.poll_device_token(grant))
+ await entered.wait()
+ task.cancel()
+ release.set()
+ with pytest.raises(asyncio.CancelledError):
+ await task
+ assert stream.closed
+ assert transport.closed
+ assert len(provider.token_forms) == 1
diff --git a/uv.lock b/uv.lock
index b007865f..e1e53dc7 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2337,7 +2337,7 @@ wheels = [
[[package]]
name = "pyfly"
-version = "26.9.13"
+version = "26.9.14"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },
diff --git a/web/index.html b/web/index.html
index f0af492a..c3d941ef 100644
--- a/web/index.html
+++ b/web/index.html
@@ -91,7 +91,7 @@