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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.10.01-brightgreen" alt="Version: 26.10.01"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-26.10.02-brightgreen" alt="Version: 26.10.02"></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 @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.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
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.10.01
✓ pyfly v26.10.02

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.10.01)` |
| `MINIMAL` | Single line: `:: PyFly :: (v26.10.02)` |
| `OFF` | No banner output at all. |

### BannerPrinter Class
Expand Down Expand Up @@ -640,7 +640,7 @@ ______ ___.__._/ ____\ | ___.__.
| __// ____| |__| |____/ ____|
|__| \/ \/

:: PyFly Framework :: (v26.10.01)
:: PyFly Framework :: (v26.10.02)
```

### Custom Banner Files
Expand Down
65 changes: 59 additions & 6 deletions docs/modules/oauth2-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
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.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. |
Expand Down Expand Up @@ -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)
```

---
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.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
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.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"
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.10.01"
__version__ = "26.10.02"
112 changes: 102 additions & 10 deletions src/pyfly/oauth2/acquisition.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading