From f6a6f32495d0ba6f702e3a9804160445a04fb69e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Sun, 7 Jun 2026 20:59:49 +0200 Subject: [PATCH] feat(security): pluggable persistent OAuth2 token store (Redis + Postgres) + bump v26.06.69 The authz server's TokenStore was InMemoryTokenStore only -> refresh tokens lost on restart + no cross-instance revocation (multi-instance production blocker, per the ports/adapters audit). Now pluggable via pyfly.security.oauth2.token-store.provider=memory|redis|postgres: - RedisTokenStore (security/adapters/redis_token_store.py): JSON + EX ttl (refresh-token lifetime). - PostgresTokenStore (security/adapters/postgres_token_store.py): durable table, lazy idempotent create, table-name validated. Lazy injected AsyncEngine. Hexagonal: drivers obtained in the composition root + injected; no module-scope driver imports. Tests: tests/security/test_persistent_token_store.py (unit, fake clients) + tests/integration/ test_token_store_integration.py (real Redis + real Postgres). Gates: mypy --strict (636), ruff + format, full suite 3938 passed. --- CHANGELOG.md | 18 ++++ README.md | 2 +- pyproject.toml | 2 +- src/pyfly/__init__.py | 2 +- src/pyfly/security/adapters/__init__.py | 14 +++ .../security/adapters/postgres_token_store.py | 97 +++++++++++++++++++ .../security/adapters/redis_token_store.py | 55 +++++++++++ src/pyfly/security/auto_configuration.py | 35 ++++++- .../test_token_store_integration.py | 72 ++++++++++++++ tests/security/test_persistent_token_store.py | 77 +++++++++++++++ tests/test_hexagonal.py | 4 +- uv.lock | 2 +- 12 files changed, 374 insertions(+), 6 deletions(-) create mode 100644 src/pyfly/security/adapters/__init__.py create mode 100644 src/pyfly/security/adapters/postgres_token_store.py create mode 100644 src/pyfly/security/adapters/redis_token_store.py create mode 100644 tests/integration/test_token_store_integration.py create mode 100644 tests/security/test_persistent_token_store.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b034b2..cd801c7 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.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) diff --git a/README.md b/README.md index c309823..95173ec 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.06.68 + Version: 26.06.69 Type Checked: mypy strict Code Style: Ruff Async First diff --git a/pyproject.toml b/pyproject.toml index db7baf4..c0109ae 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.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" diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py index 5d4d243..0981a31 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.06.68" +__version__ = "26.06.69" diff --git a/src/pyfly/security/adapters/__init__.py b/src/pyfly/security/adapters/__init__.py new file mode 100644 index 0000000..810b634 --- /dev/null +++ b/src/pyfly/security/adapters/__init__.py @@ -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.).""" diff --git a/src/pyfly/security/adapters/postgres_token_store.py b/src/pyfly/security/adapters/postgres_token_store.py new file mode 100644 index 0000000..03c6c8c --- /dev/null +++ b/src/pyfly/security/adapters/postgres_token_store.py @@ -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}) diff --git a/src/pyfly/security/adapters/redis_token_store.py b/src/pyfly/security/adapters/redis_token_store.py new file mode 100644 index 0000000..ed6c7d2 --- /dev/null +++ b/src/pyfly/security/adapters/redis_token_store.py @@ -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)) diff --git a/src/pyfly/security/auto_configuration.py b/src/pyfly/security/auto_configuration.py index 1914c18..eb025a1 100644 --- a/src/pyfly/security/auto_configuration.py +++ b/src/pyfly/security/auto_configuration.py @@ -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 @@ -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, @@ -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 diff --git a/tests/integration/test_token_store_integration.py b/tests/integration/test_token_store_integration.py new file mode 100644 index 0000000..192ea7c --- /dev/null +++ b/tests/integration/test_token_store_integration.py @@ -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() diff --git a/tests/security/test_persistent_token_store.py b/tests/security/test_persistent_token_store.py new file mode 100644 index 0000000..cc76d94 --- /dev/null +++ b/tests/security/test_persistent_token_store.py @@ -0,0 +1,77 @@ +# 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. +"""Persistent OAuth2 token stores (v26.06.69) — Redis + Postgres adapters, unit tests.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from pyfly.security.adapters.postgres_token_store import PostgresTokenStore +from pyfly.security.adapters.redis_token_store import RedisTokenStore + + +class _FakeRedis: + def __init__(self) -> None: + self.store: dict[str, str] = {} + self.ttls: dict[str, int] = {} + + async def set(self, key: str, value: str, ex: int | None = None) -> None: + self.store[key] = value + if ex is not None: + self.ttls[key] = ex + + async def get(self, key: str) -> Any: + return self.store.get(key) + + async def delete(self, key: str) -> None: + self.store.pop(key, None) + + +@pytest.mark.asyncio +async def test_redis_token_store_roundtrip_with_ttl() -> None: + redis = _FakeRedis() + store = RedisTokenStore(redis, ttl=900) + await store.store("tok1", {"sub": "alice", "scope": "read"}) + assert await store.find("tok1") == {"sub": "alice", "scope": "read"} + assert redis.ttls["pyfly:oauth2:token:tok1"] == 900 # refresh-token TTL applied + await store.revoke("tok1") + assert await store.find("tok1") is None + + +@pytest.mark.asyncio +async def test_redis_token_store_decodes_bytes() -> None: + redis = _FakeRedis() + store = RedisTokenStore(redis) + await store.store("t", {"a": 1}) + redis.store["pyfly:oauth2:token:t"] = redis.store["pyfly:oauth2:token:t"].encode("utf-8") # type: ignore[assignment] + assert await store.find("t") == {"a": 1} + + +def test_postgres_token_store_rejects_bad_table() -> None: + with pytest.raises(ValueError, match="table name"): + PostgresTokenStore(lambda: object(), table="t; DROP TABLE x") + + +def test_token_store_provider_selection() -> None: + from pyfly.container.container import Container + from pyfly.core.config import Config + from pyfly.security.auto_configuration import OAuth2AuthorizationServerAutoConfiguration + from pyfly.security.oauth2.authorization_server import InMemoryTokenStore + + ac = OAuth2AuthorizationServerAutoConfiguration() + assert isinstance(ac._build_token_store(Config({}), Container(), 86400), InMemoryTokenStore) + pg_cfg = Config({"pyfly": {"security": {"oauth2": {"token-store": {"provider": "postgres"}}}}}) + assert isinstance(ac._build_token_store(pg_cfg, Container(), 86400), PostgresTokenStore) diff --git a/tests/test_hexagonal.py b/tests/test_hexagonal.py index 02944e8..8ebf1b5 100644 --- a/tests/test_hexagonal.py +++ b/tests/test_hexagonal.py @@ -68,7 +68,9 @@ def test_sqlalchemy_only_in_data_and_cli(self): "and '/scheduling/adapters/' not in l " "and 'scheduling/auto_configuration' not in l " "and '/session/adapters/' not in l " - "and 'session/auto_configuration' not in l]; " + "and 'session/auto_configuration' not in l " + "and '/security/adapters/' not in l " + "and 'security/auto_configuration' not in l]; " "print('\\n'.join(bad) if bad else 'CLEAN'); " "sys.exit(len(bad))", ], diff --git a/uv.lock b/uv.lock index 4b3117b..1fb488e 100644 --- a/uv.lock +++ b/uv.lock @@ -1981,7 +1981,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.68" +version = "26.6.69" source = { editable = "." } dependencies = [ { name = "pydantic" },