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 @@
-
+
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" },