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 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.09.13 + Version: 26.09.14 Type Checked: mypy strict Code Style: Ruff Async First @@ -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 @@

Apache 2.0 mypy strict async-first - v26.09.13 + v26.09.14