Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<a href="https://github.com/fireflyframework"><img src="https://img.shields.io/badge/Firefly_Framework-official-ff6600?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyeiIvPjwvc3ZnPg==" alt="Firefly Framework"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License: Apache 2.0"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-26.09.13-brightgreen" alt="Version: 26.09.13"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-26.09.14-brightgreen" alt="Version: 26.09.14"></a>
<a href="https://mypy-lang.org/"><img src="https://img.shields.io/badge/type--checked-mypy%20strict-blue?logo=python&logoColor=white" alt="Type Checked: mypy strict"></a>
<a href="https://docs.astral.sh/ruff/"><img src="https://img.shields.io/badge/code%20style-ruff-purple?logo=ruff&logoColor=white" alt="Code Style: Ruff"></a>
<a href="#philosophy"><img src="https://img.shields.io/badge/async-first-brightgreen" alt="Async First"></a>
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ PyFly Doctor
✓ mypy — Type checker

PyFly packages:
✓ pyfly v26.09.13
✓ pyfly v26.09.14

All checks passed!
```
Expand Down
4 changes: 2 additions & 2 deletions docs/modules/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -640,7 +640,7 @@ ______ ___.__._/ ____\ | ___.__.
| __// ____| |__| |____/ ____|
|__| \/ \/

:: PyFly Framework :: (v26.09.13)
:: PyFly Framework :: (v26.09.14)
```

### Custom Banner Files
Expand Down
28 changes: 27 additions & 1 deletion docs/modules/oauth2-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions docs/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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)
```

---
Expand Down
2 changes: 1 addition & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/pyfly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""

__version__ = "26.09.13"
__version__ = "26.09.14"
30 changes: 24 additions & 6 deletions src/pyfly/oauth2/acquisition.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,16 @@ 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
expires_at: float
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:
Expand Down Expand Up @@ -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:
Expand All @@ -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()
Expand All @@ -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:
Expand Down
25 changes: 15 additions & 10 deletions tests/oauth2/test_acquisition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 = []
Expand All @@ -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"]
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()
Expand All @@ -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 = []
Expand All @@ -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
Expand All @@ -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]
Expand Down
Loading
Loading