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.06.69 (2026-06-07)

### Added (OAuth2 — persistent token stores; fixes a multi-instance production blocker)

The OAuth2 authorization server's `TokenStore` was in-memory only, so refresh tokens were lost
on restart and revocation didn't propagate across instances. It is now pluggable via
`pyfly.security.oauth2.token-store.provider`:

- **`RedisTokenStore`** (`provider=redis`) — JSON values with a TTL set to the refresh-token
lifetime (expired tokens self-evict); fast cross-instance revocation.
- **`PostgresTokenStore`** (`provider=postgres`) — durable, auditable token rows; lazy
idempotent table creation; table name validated against injection.
- `memory` (default) keeps `InMemoryTokenStore` for dev/test.

Hexagonal: the Redis client / SQLAlchemy engine are obtained in the composition root and
injected; the adapters import no driver at module scope. Both validated against **real Redis
and real Postgres** (testcontainers: store/find/upsert/revoke).

## v26.06.68 (2026-06-07)

### Added (session — Postgres SessionRegistry; Postgres parity)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,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="#"><img src="https://img.shields.io/badge/version-26.06.68-brightgreen" alt="Version: 26.06.68"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.69-brightgreen" alt="Version: 26.06.69"></a>
<a href="#"><img src="https://img.shields.io/badge/type--checked-mypy%20strict-blue?logo=python&logoColor=white" alt="Type Checked: mypy strict"></a>
<a href="#"><img src="https://img.shields.io/badge/code%20style-ruff-purple?logo=ruff&logoColor=white" alt="Code Style: Ruff"></a>
<a href="#"><img src="https://img.shields.io/badge/async-first-brightgreen" alt="Async First"></a>
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.6.68"
version = "26.6.69"
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.06.68"
__version__ = "26.06.69"
14 changes: 14 additions & 0 deletions src/pyfly/security/adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Copyright 2026 Firefly Software Foundation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Security outbound adapters (persistent OAuth2 token stores, etc.)."""
97 changes: 97 additions & 0 deletions src/pyfly/security/adapters/postgres_token_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Copyright 2026 Firefly Software Foundation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Postgres table-backed OAuth2 :class:`TokenStore` adapter.

Durable, auditable refresh-token storage + cross-instance revocation for a multi-instance
authorization server, with no Redis required. Hexagonal: the SQLAlchemy ``AsyncEngine`` is
injected lazily by the composition root; this module imports no SQLAlchemy at module scope.
The backing table is created lazily and idempotently on first use.
"""

from __future__ import annotations

import asyncio
import json
import re
from collections.abc import Callable
from typing import Any

_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")


class PostgresTokenStore:
"""OAuth2 token store in a Postgres table (token_id PK, data JSON text)."""

def __init__(self, engine_factory: Callable[[], Any], *, table: str = "pyfly_oauth2_tokens") -> None:
if not _IDENT.match(table):
raise ValueError(f"Invalid token-store table name: {table!r}")
self._engine_factory = engine_factory
self._engine: Any = None
self._table = table
self._ensured = False
self._guard = asyncio.Lock()

def _eng(self) -> Any:
if self._engine is None:
self._engine = self._engine_factory()
return self._engine

async def _ensure_table(self) -> None:
if self._ensured:
return
from sqlalchemy import text

async with self._guard:
if self._ensured:
return
async with self._eng().begin() as conn:
await conn.execute(
text(f"CREATE TABLE IF NOT EXISTS {self._table} (token_id TEXT PRIMARY KEY, data TEXT NOT NULL)")
)
self._ensured = True

async def store(self, token_id: str, token_data: dict[str, Any]) -> None:
from sqlalchemy import text

await self._ensure_table()
async with self._eng().begin() as conn:
await conn.execute(
text(
f"INSERT INTO {self._table} (token_id, data) VALUES (:i, :d) "
"ON CONFLICT (token_id) DO UPDATE SET data = EXCLUDED.data"
),
{"i": token_id, "d": json.dumps(token_data)},
)

async def find(self, token_id: str) -> dict[str, Any] | None:
from sqlalchemy import text

await self._ensure_table()
async with self._eng().connect() as conn:
result = await conn.execute(
text(f"SELECT data FROM {self._table} WHERE token_id = :i"),
{"i": token_id},
)
row = result.first()
if row is None:
return None
data: dict[str, Any] = json.loads(row[0])
return data

async def revoke(self, token_id: str) -> None:
from sqlalchemy import text

await self._ensure_table()
async with self._eng().begin() as conn:
await conn.execute(text(f"DELETE FROM {self._table} WHERE token_id = :i"), {"i": token_id})
55 changes: 55 additions & 0 deletions src/pyfly/security/adapters/redis_token_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Copyright 2026 Firefly Software Foundation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Redis-backed OAuth2 :class:`TokenStore` adapter.

Cross-instance refresh-token persistence + fast distributed revocation for a multi-instance
authorization server. Hexagonal: the async Redis client is injected by the composition root;
this module never imports ``redis``. Tokens are stored as JSON strings with an optional TTL
(typically the refresh-token lifetime) so expired tokens self-evict.
"""

from __future__ import annotations

import json
from typing import Any


class RedisTokenStore:
"""OAuth2 token store over an injected async Redis client."""

def __init__(self, client: Any, *, ttl: int | None = None, key_prefix: str = "pyfly:oauth2:token:") -> None:
self._client = client
self._ttl = ttl
self._prefix = key_prefix

def _key(self, token_id: str) -> str:
return f"{self._prefix}{token_id}"

async def store(self, token_id: str, token_data: dict[str, Any]) -> None:
payload = json.dumps(token_data)
if self._ttl:
await self._client.set(self._key(token_id), payload, ex=self._ttl)
else:
await self._client.set(self._key(token_id), payload)

async def find(self, token_id: str) -> dict[str, Any] | None:
raw = await self._client.get(self._key(token_id))
if raw is None:
return None
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
return json.loads(raw) # type: ignore[no-any-return]

async def revoke(self, token_id: str) -> None:
await self._client.delete(self._key(token_id))
35 changes: 34 additions & 1 deletion src/pyfly/security/auto_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@
WebFilter = object # type: ignore[misc,assignment]

from collections.abc import Sequence
from typing import Any

from pyfly.config.auto import AutoConfiguration
from pyfly.container.bean import bean
from pyfly.container.container import Container
from pyfly.container.exceptions import NoSuchBeanError, NoUniqueBeanError
Expand Down Expand Up @@ -186,12 +188,13 @@ def authorization_server(
self,
config: Config,
client_registration_repository: InMemoryClientRegistrationRepository,
container: Container,
) -> AuthorizationServer:
secret = str(config.get("pyfly.security.oauth2.authorization-server.secret", "change-me-in-production"))
issuer = config.get("pyfly.security.oauth2.authorization-server.issuer")
access_ttl = int(config.get("pyfly.security.oauth2.authorization-server.access-token-ttl", 3600))
refresh_ttl = int(config.get("pyfly.security.oauth2.authorization-server.refresh-token-ttl", 86400))
token_store = InMemoryTokenStore()
token_store = self._build_token_store(config, container, refresh_ttl)
return AuthorizationServer(
secret=secret,
client_repository=client_registration_repository,
Expand All @@ -201,6 +204,36 @@ def authorization_server(
issuer=str(issuer) if issuer is not None else None,
)

def _build_token_store(self, config: Config, container: Container, refresh_ttl: int) -> Any:
"""Select the token-store backend (Spring parity for a persistent authorization server).

``pyfly.security.oauth2.token-store.provider``: ``memory`` (default, single-instance),
``redis`` (fast cross-instance revocation, TTL = refresh-token lifetime), or ``postgres``
(durable + auditable). The Redis client / SQLAlchemy engine are obtained here (the
composition root) and injected — the adapters never import their driver at module scope.
"""
provider = str(config.get("pyfly.security.oauth2.token-store.provider", "memory")).lower()
if provider == "redis" and AutoConfiguration.is_available("redis.asyncio"):
import redis.asyncio as aioredis

from pyfly.security.adapters.redis_token_store import RedisTokenStore

url = str(
config.get("pyfly.security.oauth2.token-store.redis.url")
or config.get("pyfly.session.redis.url", "redis://localhost:6379/0")
)
return RedisTokenStore(aioredis.from_url(url), ttl=refresh_ttl) # type: ignore[no-untyped-call,unused-ignore]
if provider == "postgres":
from pyfly.security.adapters.postgres_token_store import PostgresTokenStore

def _engine() -> Any:
from sqlalchemy.ext.asyncio import AsyncEngine

return container.resolve(AsyncEngine)

return PostgresTokenStore(_engine)
return InMemoryTokenStore()


# ---------------------------------------------------------------------------
# OAuth2 Client
Expand Down
72 changes: 72 additions & 0 deletions tests/integration/test_token_store_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Copyright 2026 Firefly Software Foundation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Integration tests: persistent OAuth2 token stores against real Redis + Postgres (v26.06.69)."""

from __future__ import annotations

from collections.abc import Iterator

import pytest

from pyfly.testing import postgres_container, pyfly_config_for, redis_container, requires_docker


@pytest.fixture
def redis_url() -> Iterator[str]:
with redis_container() as container:
yield f"redis://{container.get_container_host_ip()}:{container.get_exposed_port(6379)}/0"


@pytest.fixture
def pg_url() -> Iterator[str]:
with postgres_container() as container:
yield pyfly_config_for(container)["pyfly.data.relational.url"]


@requires_docker
@pytest.mark.asyncio
async def test_redis_token_store_against_real_redis(redis_url: str) -> None:
import redis.asyncio as aioredis

from pyfly.security.adapters.redis_token_store import RedisTokenStore

client = aioredis.from_url(redis_url)
try:
store = RedisTokenStore(client, ttl=60)
await store.store("tok", {"sub": "bob", "scope": "write"})
assert await store.find("tok") == {"sub": "bob", "scope": "write"}
await store.revoke("tok")
assert await store.find("tok") is None
finally:
await client.aclose()


@requires_docker
@pytest.mark.asyncio
async def test_postgres_token_store_against_real_postgres(pg_url: str) -> None:
from sqlalchemy.ext.asyncio import create_async_engine

from pyfly.security.adapters.postgres_token_store import PostgresTokenStore

engine = create_async_engine(pg_url)
try:
store = PostgresTokenStore(lambda: engine)
await store.store("tok", {"sub": "carol"})
assert await store.find("tok") == {"sub": "carol"}
await store.store("tok", {"sub": "carol", "rotated": True}) # upsert
assert await store.find("tok") == {"sub": "carol", "rotated": True}
await store.revoke("tok")
assert await store.find("tok") is None
finally:
await engine.dispose()
Loading
Loading