From 80e5ad9218f6e57c3795451c6a63ee76928c4ff6 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 15:13:06 +0530 Subject: [PATCH 01/26] docs: design stateless production deployments --- ...stateless-production-deployments-design.md | 310 ++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-10-stateless-production-deployments-design.md diff --git a/docs/superpowers/specs/2026-06-10-stateless-production-deployments-design.md b/docs/superpowers/specs/2026-06-10-stateless-production-deployments-design.md new file mode 100644 index 00000000..9f1b6d5e --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-stateless-production-deployments-design.md @@ -0,0 +1,310 @@ +# Stateless Production Deployments Design + +## Summary + +Prepare Authsome for stateless, horizontally scalable production deployments while preserving the local developer defaults. The server will select production infrastructure from environment variables: Postgres for the relational server Store when `AUTHSOME_DATABASE_URL` uses a Postgres scheme, and Redis for shared mutable server state plus encrypted vault KV when `AUTHSOME_REDIS_URL` is present. + +The design keeps the existing module ownership model intact. `identity`, `auth`, and `vault` remain reusable libraries with infrastructure-agnostic contracts and domain behavior. `server` remains the composition root that chooses concrete infrastructure and combines the libraries into Authsome business logic. + +## Goals + +- Make container and multi-replica deployments viable without relying on local process memory or ephemeral disk for hot-path mutable state. +- Keep SQLite, disk vault storage, and in-memory transient state working for local development and tests. +- Reuse `py-key-value-aio` Redis support for vault storage instead of creating a custom Redis vault backend. +- Keep Postgres and Redis optional for library installs, while installing production extras in the Docker image. +- Provide self-hosting documentation with Postgres, Redis, Docker, and secret-management guidance. + +## Non-Goals + +- Do not introduce an ORM or Alembic for this refactor. Use a lightweight schema-version migration runner inside the existing Store adapter. +- Do not move business logic into CLI or proxy. They continue to communicate with the server. +- Do not add stateful browser sessions in this refactor. Browser sessions remain signed stateless JWT cookies. +- Do not add email verification or signup abuse prevention in this refactor. Those are tracked separately in GitHub issue #411. +- Do not introduce `AUTHSOME_VAULT_BACKEND`. Redis vault selection follows `AUTHSOME_REDIS_URL`. + +## Current State + +The current code already has a relational server Store split from vault storage: + +- `src/authsome/server/store/database.py` supports SQLite and Postgres URL resolution, but Postgres uses a single `asyncpg` connection. +- `src/authsome/server/store/repositories.py` contains the five server-owned registries plus server config, custom provider definitions, and audit events. +- `src/authsome/server/dependencies.py` always creates the vault with `DiskStore`. +- `src/authsome/auth/sessions.py` stores auth flow sessions in process memory. +- `src/authsome/server/ui_sessions.py` keeps browser sessions stateless but stores pending identity-claim tokens in process memory. +- `src/authsome/identity/proof.py` validates PoP JWTs and currently owns an in-memory replay cache. +- `src/authsome/server/app.py` wires these components directly into `app.state`. + +These defaults work for local development but do not work across multiple replicas. Auth flow sessions, pending claim tokens, and PoP replay JTIs need shared state. Vault encrypted blobs need a backend that survives container restarts without requiring a mounted local volume in production. + +## Architecture + +Authsome keeps the existing boundaries: + +- `identity` owns identity and PoP token semantics. It creates PoP JWTs, verifies signatures, verifies request binding, and extracts proof claims. It remains infrastructure agnostic. +- `auth` owns flow/session models and abstract session-store behavior. Concrete Redis storage does not leak into auth flow code. +- `vault` owns encrypted KV semantics over an `AsyncKeyValue`. It does not define a Redis-specific vault API. +- `server` owns deployment topology. It selects SQLite or Postgres, DiskStore or RedisStore, memory or Redis state stores, and wires the selected implementations into services and routes. +- `cli` and `proxy` remain clients of the server business logic. +- `ui` and the relational Store remain server properties. + +Backend selection is simple: + +- `AUTHSOME_DATABASE_URL=postgresql://...` or `postgres://...` selects Postgres for the relational server Store. +- No Postgres URL selects SQLite. +- `AUTHSOME_REDIS_URL` selects Redis for auth flow sessions, pending claim tokens, PoP JTI replay cache, and raw vault KV. +- No Redis URL selects in-memory transient stores and disk vault KV. + +If an explicit Postgres or Redis backend is configured and the driver is missing or the service is unreachable, startup fails. There is no runtime fallback from Redis/Postgres to memory/disk after startup. + +Browser UI sessions stay stateless signed cookies for now. The disadvantages are known: server-side logout/revocation and active session visibility are not available. Verified signup and stateful browser-session management are deferred to issue #411. + +## Components + +### Server Configuration + +`src/authsome/server/config.py` will add: + +- `redis_url: str | None` +- Postgres pool settings, such as min and max pool size. +- TTL settings used by Redis-backed auth sessions, pending claim tokens, and replay cache where existing constants are currently hard-coded. + +Configuration remains environment-driven through the existing `AUTHSOME_` prefix. + +### Relational Store + +`src/authsome/server/store/database.py` keeps the current lightweight adapter but upgrades production behavior: + +- SQLite continues to use one `aiosqlite` connection. +- Postgres uses an `asyncpg` pool. +- Queries still use `?` placeholders at repository call sites, translated to Postgres positional parameters inside the adapter. +- Startup runs a lightweight schema-version migration runner. + +The migration runner should: + +- Maintain `store_schema_version`. +- Apply ordered migration functions or statements. +- Support SQLite and Postgres dialect fragments inside the Store module. +- Keep existing `CREATE TABLE IF NOT EXISTS` bootstrap behavior only as migration contents, not as ad hoc schema setup scattered through startup. + +The existing registries remain repository classes. They should not learn about pools, Postgres clients, or migration internals. + +### Replay Cache + +The anti-replay cache prevents reuse of a PoP JWT within its validity window. Each PoP JWT has a `jti`. After signature, method, URL, body hash, and expiry validation, the server checks whether that `jti` has already been used. If it has, the request is rejected. + +The split should be: + +- `identity.proof` owns proof semantics and accepts an injected infrastructure-agnostic replay checker. +- A tiny protocol defines the operation shape: `check_and_store(jti: str, exp: int) -> None`. +- Server-side implementations provide storage: + - Memory implementation for local dev and tests. + - Redis implementation for production. + +The Redis implementation should use an atomic set-if-not-exists operation with a TTL derived from `exp - now`. This lets replica B reject a JWT already accepted by replica A. + +No Redis import belongs in `identity`. + +### Auth Flow Sessions + +`AuthSession` remains the domain model in `src/authsome/auth/sessions.py`. + +The current in-memory `AuthSessionStore` behavior should be preserved behind a small store interface that covers the existing route needs: + +- `create(...)` +- `get(session_id)` +- `save(session)` +- `delete(session_id)` +- `index_oauth_state(session)` +- `get_by_oauth_state(state)` + +A Redis implementation can live server-side if it imports Redis-specific code. It should serialize `AuthSession` with Pydantic JSON, store each session under a namespaced key, and store OAuth state-to-session mappings under separate keys with matching TTLs. + +Local memory behavior remains available when `AUTHSOME_REDIS_URL` is absent. + +### Pending Claim Tokens + +Browser sessions remain stateless in `UiSessionStore`, but pending claim tokens need shared mutable state so claim links survive replica changes. + +The browser session methods stay simple: + +- `create_browser_session(...)` +- `get_browser_session(cookie_value)` +- `build_cookie_value(token)` +- `delete_browser_session(cookie_value)` + +Pending claim methods move behind a memory/Redis store: + +- `create_pending_claim(identity, ttl_seconds)` +- `get_pending_claim(token)` +- `consume_pending_claim(token)` + +`consume_pending_claim` should delete and return the token. The Redis version should be atomic where the Redis client makes that practical. + +### Vault KV Backend + +The vault continues to use `Vault -> AesGcmEncryptionWrapper -> AsyncKeyValue`. + +`src/authsome/server/dependencies.py` chooses the raw `AsyncKeyValue`: + +- No `AUTHSOME_REDIS_URL`: `DiskStore(directory=server_config.kv_store_dir)` +- `AUTHSOME_REDIS_URL`: `key_value.aio.stores.redis.RedisStore(url=server_config.redis_url)` + +The existing `DekManager` continues to load or create the wrapped DEK record through the raw KV backend. Redis stores only encrypted vault values and DEK wrapping metadata. The vault master key is never stored in Redis. + +### Secrets + +Master-key resolution keeps the current behavior in `src/authsome/server/secrets.py`: + +1. `AUTHSOME_MASTER_KEY` +2. `AUTHSOME_MASTER_KEY_FILE` or the default server key file +3. OS keyring +4. Generate a new base64 key, store it in keyring if possible, otherwise write the default key file + +There is no special production-mode enforcement tied to Redis or Postgres. The self-hosting guide should recommend `AUTHSOME_MASTER_KEY` or `AUTHSOME_MASTER_KEY_FILE` for containers and explain that generated file keys only survive when the filesystem is persistent. + +### App Lifecycle + +`src/authsome/server/app.py` remains the composition root: + +1. Load `ServerConfig`. +2. Open and migrate the relational Store. +3. If Redis is configured, create or validate Redis-backed state dependencies. +4. Create raw vault KV, load/create DEK, wrap with encryption, and construct `Vault`. +5. Create auth sessions, UI sessions/pending claim store, replay cache, provider repository, account auth service, bootstrap service, and ownership resolver. +6. Close Store pools and Redis-owned clients on shutdown. + +The existing `ownership_cache = {}` can remain a local optimization only if it is not correctness-critical. If it can become stale across replicas for claim/binding changes, it should be removed or given a conservative TTL. Correctness must come from the registries, not the process cache. + +## Data Flow + +### Startup + +Local startup without production URLs uses SQLite, DiskStore, and memory state. Postgres is selected only by a Postgres `AUTHSOME_DATABASE_URL`; Redis is selected only by `AUTHSOME_REDIS_URL`. + +If Redis is configured, startup should ping Redis before serving requests. If Postgres is configured, startup should acquire a connection from the pool and run migrations before serving requests. + +### PoP Requests + +1. The request arrives with `Authorization: PoP `. +2. `identity.proof.validate_proof_jwt()` validates the signature and request binding. +3. The injected replay checker stores the `jti` until expiry or raises if already seen. +4. The server resolves the identity registration and ownership through the relational Store. +5. The route receives the existing `ResolvedOwnership` and builds `CredentialService`. + +### Auth Flow Sessions + +1. A login flow creates an `AuthSession`. +2. The selected session store persists it with a TTL. +3. OAuth flows index `internal_state` to the session id. +4. Callback routes resolve the session by OAuth state or session id, update the session, and save it. +5. Expired or missing sessions behave as not found. + +### Pending Claim Links + +1. Identity bootstrap creates a pending claim token. +2. The selected pending claim store persists it with a TTL. +3. The claim route consumes the token. +4. Consumed or expired tokens behave as not found. + +### Vault Access + +1. `CredentialRepository` reads or writes credentials through `Vault`. +2. `Vault` updates its index records and plaintext domain values. +3. `AesGcmEncryptionWrapper` encrypts the values. +4. DiskStore or RedisStore stores encrypted blobs using the existing collection/key naming scheme, including `vault::...` collections. + +## Error Handling + +Startup failures: + +- Invalid database URL scheme fails clearly. +- Postgres driver missing, connection failure, bad credentials, or migration failure fails startup. +- Redis driver missing, connection failure, bad credentials, or ping failure fails startup. +- Vault DEK unwrap failure fails startup. + +Runtime behavior: + +- Redis outages during affected operations return 5xx responses. The server does not silently fall back to memory or disk. +- PoP replay detection returns the existing unauthorized proof-validation response. +- Expired sessions and pending claim tokens behave as not found. +- Health remains cheap and public. Keep `/api/health` and add a root `/health` alias for container health checks. +- Readiness checks the relational Store and vault. If Redis is configured, readiness also checks Redis connectivity. + +## Docker And Self-Hosting + +The Docker image should install production extras by default while the base Python package keeps them optional where possible. + +The Dockerfile should: + +- Keep a multi-stage build for UI and Python package. +- Use the `uv` toolchain for Python build/install. +- Run as a non-root user. +- Expose port 7998. +- Add a root `/health` alias backed by the same response as `/api/health`. +- Include a healthcheck against `/health`. + +`docker-compose.yml` should include: + +- `authsome` +- `postgres` +- `redis` + +The self-hosting guide should cover: + +- Prerequisites: Docker, Postgres, Redis. +- Environment variables: `AUTHSOME_DATABASE_URL`, `AUTHSOME_REDIS_URL`, `AUTHSOME_MASTER_KEY`, `AUTHSOME_MASTER_KEY_FILE`, `AUTHSOME_HOME`, `AUTHSOME_BASE_URL`, `AUTHSOME_HOST`, `AUTHSOME_PORT`, and analytics settings. +- Startup steps: pull or build image, set env vars, start service, run `authsome init`, verify `/health`. +- Compose example for local production simulation. +- Secret guidance: do not commit production `AUTHSOME_MASTER_KEY`; prefer a cloud secret manager, Doppler, Vault, or platform secrets. +- Migration guidance: relational schema migrations run at startup; back up Postgres and Redis according to operator policy. + +## Testing + +Default `uv run pytest` should continue to pass without external services. + +Tests to add or adjust: + +- SQLite migration tests. +- Postgres migration tests gated behind an optional service fixture or environment variable. +- Postgres pool adapter tests gated behind the same integration mechanism. +- Memory replay cache tests after moving it out of `identity`. +- Redis replay cache tests for duplicate rejection and TTL behavior. +- Auth session store contract tests run against memory and Redis implementations. +- Pending claim store contract tests run against memory and Redis implementations. +- Vault backend tests showing RedisStore is selected when `AUTHSOME_REDIS_URL` is present and values remain encrypted. +- Server lifecycle tests for local defaults and Redis/Postgres selection failures. +- Existing session recreation tests should split local and Redis behavior: memory sessions do not survive app recreation; Redis sessions do. +- Docker smoke test for image build and `/health`. + +Verification before completion should include: + +- `uv run pytest` +- `uv run ruff check` +- `uv run ty check` +- Docker build smoke test when Docker is available +- Redis/Postgres integration tests when services are available + +## Rollout Plan + +Implement in small phases inside one production-readiness branch: + +1. Add config fields and optional dependency extras. +2. Upgrade the relational Store to Postgres pooling and lightweight migrations. +3. Split replay-cache semantics cleanly from `identity` and add memory/Redis implementations. +4. Introduce auth session store contracts and Redis-backed auth sessions. +5. Split pending claim storage from stateless browser session signing and add Redis pending claims. +6. Reuse `py-key-value-aio[redis]` for vault raw KV when `AUTHSOME_REDIS_URL` is configured. +7. Update app lifecycle, readiness, Dockerfile, compose, and self-hosting docs. +8. Add gated integration tests and smoke verification. + +Each phase should preserve local defaults and keep implementation changes close to the modules that own the behavior. + +## Open Follow-Up + +GitHub issue #411 tracks hosted login hardening outside this refactor: + +- Email verification during signup. +- Signup abuse prevention. +- Stateful browser sessions. +- Server-side browser-session logout and revocation. +- Session visibility and account-security policies. From f4a6cf5a16f9eb711f943b97226db442149b3107 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 15:28:16 +0530 Subject: [PATCH 02/26] feat: add production backend config --- pyproject.toml | 9 ++++++++- src/authsome/server/config.py | 3 +++ tests/server/test_config.py | 31 +++++++++++++++++++++++++++++++ uv.lock | 26 +++++++++++++++++++++++--- 4 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 tests/server/test_config.py diff --git a/pyproject.toml b/pyproject.toml index 585ecca8..8355e34d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,6 @@ dependencies = [ "python-multipart>=0.0.27", "py-key-value-aio[disk]", "aiosqlite>=0.20", - "asyncpg>=0.30", "pyjwt>=2.12.1", "argon2-cffi>=25.1.0", "base58>=2.1.1", @@ -49,6 +48,14 @@ dependencies = [ ] [project.optional-dependencies] +postgres = [ + "asyncpg>=0.30", +] +redis = [ + "redis>=5.0", + "py-key-value-aio[redis]", +] + dev = [ "pytest>=7.0", "pytest-asyncio>=1.3.0", diff --git a/src/authsome/server/config.py b/src/authsome/server/config.py index 746aa9b8..ad95e582 100644 --- a/src/authsome/server/config.py +++ b/src/authsome/server/config.py @@ -17,6 +17,9 @@ class ServerConfig(AuthsomeConfig): # Store database_url: str | None = Field(default=None, validation_alias="DATABASE_URL") + redis_url: str | None = None + postgres_pool_min_size: int = Field(default=1, ge=1) + postgres_pool_max_size: int = Field(default=10, ge=1) # Lifetimes, in seconds ui_bootstrap_ttl_seconds: int = 300 diff --git a/tests/server/test_config.py b/tests/server/test_config.py new file mode 100644 index 00000000..5eb1f7a0 --- /dev/null +++ b/tests/server/test_config.py @@ -0,0 +1,31 @@ +from pathlib import Path + +from authsome.server.config import ServerConfig + + +def test_server_config_reads_redis_url(monkeypatch) -> None: + monkeypatch.setenv("AUTHSOME_REDIS_URL", "redis://localhost:6379/0") + + config = ServerConfig() + + assert config.redis_url == "redis://localhost:6379/0" + + +def test_server_config_exposes_postgres_pool_settings(monkeypatch) -> None: + monkeypatch.setenv("AUTHSOME_POSTGRES_POOL_MIN_SIZE", "2") + monkeypatch.setenv("AUTHSOME_POSTGRES_POOL_MAX_SIZE", "9") + expected_min_pool_size = 2 + expected_max_pool_size = 9 + + config = ServerConfig() + + assert config.postgres_pool_min_size == expected_min_pool_size + assert config.postgres_pool_max_size == expected_max_pool_size + + +def test_server_config_defaults_preserve_local_paths(tmp_path: Path) -> None: + config = ServerConfig(home=tmp_path) + + assert config.redis_url is None + assert config.database == str(tmp_path / "server" / "authsome.db") + assert config.kv_store_dir == tmp_path / "server" / "kv_store" diff --git a/uv.lock b/uv.lock index c8a7e88d..95ffdc0b 100644 --- a/uv.lock +++ b/uv.lock @@ -167,7 +167,6 @@ source = { editable = "." } dependencies = [ { name = "aiosqlite" }, { name = "argon2-cffi" }, - { name = "asyncpg" }, { name = "base58" }, { name = "browser-cookie3" }, { name = "click" }, @@ -199,12 +198,19 @@ dev = [ { name = "ruff" }, { name = "ty" }, ] +postgres = [ + { name = "asyncpg" }, +] +redis = [ + { name = "py-key-value-aio", extra = ["redis"] }, + { name = "redis" }, +] [package.metadata] requires-dist = [ { name = "aiosqlite", specifier = ">=0.20" }, { name = "argon2-cffi", specifier = ">=25.1.0" }, - { name = "asyncpg", specifier = ">=0.30" }, + { name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.30" }, { name = "base58", specifier = ">=2.1.1" }, { name = "browser-cookie3", specifier = ">=0.19" }, { name = "click", specifier = ">=8.0" }, @@ -219,6 +225,7 @@ requires-dist = [ { name = "posthog", specifier = ">=3.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.6.0" }, { name = "py-key-value-aio", extras = ["disk"] }, + { name = "py-key-value-aio", extras = ["redis"], marker = "extra == 'redis'" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pydantic-settings", specifier = ">=2.0" }, { name = "pyjwt", specifier = ">=2.12.1" }, @@ -227,12 +234,13 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.4.0" }, { name = "python-multipart", specifier = ">=0.0.27" }, + { name = "redis", marker = "extra == 'redis'", specifier = ">=5.0" }, { name = "requests", specifier = ">=2.28" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9" }, { name = "ty", marker = "extra == 'dev'" }, { name = "uvicorn", specifier = ">=0.30" }, ] -provides-extras = ["dev"] +provides-extras = ["postgres", "redis", "dev"] [package.metadata.requires-dev] dev = [] @@ -1272,6 +1280,9 @@ disk = [ { name = "diskcache" }, { name = "pathvalidate" }, ] +redis = [ + { name = "redis" }, +] [[package]] name = "pyasn1" @@ -1628,6 +1639,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "redis" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/ae/ed461cca5780b5fc8b9fe8ca0ed98d89508645fb9d880c24cc42c087678f/redis-8.0.0.tar.gz", hash = "sha256:a00c5355432051ac14e593b8b197fc76c887ee12d55a0984f69328a1115fdc49", size = 5101591, upload-time = "2026-05-28T12:45:13.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/e3/b519734372d305bd547534a9f32e4ce9f98552af753dce72cf3483a0ff0b/redis-8.0.0-py3-none-any.whl", hash = "sha256:c938c18338585009f0bc310f4c7e4e4b4d37639356c4ac072cedf3af570c8dc7", size = 499870, upload-time = "2026-05-28T12:45:11.697Z" }, +] + [[package]] name = "requests" version = "2.34.2" From a87328f578c734e9f58de93a41e65e6bdf73b6cb Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 15:31:40 +0530 Subject: [PATCH 03/26] fix: lazy load postgres driver --- src/authsome/server/config.py | 8 +++++++- src/authsome/server/store/database.py | 20 ++++++++++++-------- tests/server/test_config.py | 7 +++++++ 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/authsome/server/config.py b/src/authsome/server/config.py index ad95e582..0405f862 100644 --- a/src/authsome/server/config.py +++ b/src/authsome/server/config.py @@ -3,7 +3,7 @@ from functools import lru_cache from pathlib import Path -from pydantic import AliasChoices, Field +from pydantic import AliasChoices, Field, model_validator from authsome.config import AuthsomeConfig @@ -21,6 +21,12 @@ class ServerConfig(AuthsomeConfig): postgres_pool_min_size: int = Field(default=1, ge=1) postgres_pool_max_size: int = Field(default=10, ge=1) + @model_validator(mode="after") + def validate_postgres_pool_sizes(self) -> "ServerConfig": + if self.postgres_pool_min_size > self.postgres_pool_max_size: + raise ValueError("postgres_pool_min_size must be less than or equal to postgres_pool_max_size") + return self + # Lifetimes, in seconds ui_bootstrap_ttl_seconds: int = 300 ui_session_ttl_seconds: int = 3600 diff --git a/src/authsome/server/store/database.py b/src/authsome/server/store/database.py index 738a6f67..58a6fb1d 100644 --- a/src/authsome/server/store/database.py +++ b/src/authsome/server/store/database.py @@ -8,7 +8,6 @@ from urllib.parse import urlparse import aiosqlite -import asyncpg from authsome.server.config import get_server_config @@ -125,13 +124,18 @@ async def open_store_database(config: StoreDatabaseConfig) -> StoreDatabase: if config.backend == "sqlite": db_path = Path(config.dsn) db_path.parent.mkdir(parents=True, exist_ok=True) - connection = await aiosqlite.connect(db_path) - connection.row_factory = aiosqlite.Row - await connection.execute("PRAGMA foreign_keys = ON") - await connection.commit() - database = StoreDatabase(config=config, connection=connection) - await initialize_schema(database) - return database + connection = await aiosqlite.connect(db_path) + connection.row_factory = aiosqlite.Row + await connection.execute("PRAGMA foreign_keys = ON") + await connection.commit() + database = StoreDatabase(config=config, connection=connection) + await initialize_schema(database) + return database + + try: + import asyncpg # noqa: PLC0415 + except ImportError as exc: + raise RuntimeError("Postgres Store requires installing authsome[postgres]") from exc connection = await asyncpg.connect(config.dsn) database = StoreDatabase(config=config, connection=connection) diff --git a/tests/server/test_config.py b/tests/server/test_config.py index 5eb1f7a0..09b052a9 100644 --- a/tests/server/test_config.py +++ b/tests/server/test_config.py @@ -1,5 +1,7 @@ from pathlib import Path +import pytest + from authsome.server.config import ServerConfig @@ -29,3 +31,8 @@ def test_server_config_defaults_preserve_local_paths(tmp_path: Path) -> None: assert config.redis_url is None assert config.database == str(tmp_path / "server" / "authsome.db") assert config.kv_store_dir == tmp_path / "server" / "kv_store" + + +def test_server_config_rejects_invalid_postgres_pool_range() -> None: + with pytest.raises(ValueError, match="postgres_pool_min_size must be less than or equal to postgres_pool_max_size"): + ServerConfig(postgres_pool_min_size=10, postgres_pool_max_size=2) From 7a39de61d0cbf993dee307a556249ab147b57e31 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 15:33:35 +0530 Subject: [PATCH 04/26] fix: restore sqlite store branch --- src/authsome/server/store/database.py | 14 +++++----- .../server/store/test_database_migrations.py | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 tests/server/store/test_database_migrations.py diff --git a/src/authsome/server/store/database.py b/src/authsome/server/store/database.py index 58a6fb1d..19c8dfdf 100644 --- a/src/authsome/server/store/database.py +++ b/src/authsome/server/store/database.py @@ -124,13 +124,13 @@ async def open_store_database(config: StoreDatabaseConfig) -> StoreDatabase: if config.backend == "sqlite": db_path = Path(config.dsn) db_path.parent.mkdir(parents=True, exist_ok=True) - connection = await aiosqlite.connect(db_path) - connection.row_factory = aiosqlite.Row - await connection.execute("PRAGMA foreign_keys = ON") - await connection.commit() - database = StoreDatabase(config=config, connection=connection) - await initialize_schema(database) - return database + connection = await aiosqlite.connect(db_path) + connection.row_factory = aiosqlite.Row + await connection.execute("PRAGMA foreign_keys = ON") + await connection.commit() + database = StoreDatabase(config=config, connection=connection) + await initialize_schema(database) + return database try: import asyncpg # noqa: PLC0415 diff --git a/tests/server/store/test_database_migrations.py b/tests/server/store/test_database_migrations.py new file mode 100644 index 00000000..46f72d54 --- /dev/null +++ b/tests/server/store/test_database_migrations.py @@ -0,0 +1,27 @@ +import builtins +import sys + +import pytest + +from authsome.server.store.database import StoreDatabaseConfig, open_store_database + + +@pytest.mark.asyncio +async def test_open_store_database_postgres_without_driver_raises_runtime_error(monkeypatch, tmp_path) -> None: + postgres_config = StoreDatabaseConfig(backend="postgres", dsn="postgres://localhost:5432/test", home=tmp_path) + original_import = builtins.__import__ + removed_asyncpg = sys.modules.pop("asyncpg", None) + + def fake_import(name: str, *args, **kwargs): + if name == "asyncpg": + raise ImportError("No module named 'asyncpg'") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + try: + with pytest.raises(RuntimeError, match=r"Postgres Store requires installing authsome\[postgres\]"): + await open_store_database(postgres_config) + finally: + if removed_asyncpg is not None: + sys.modules["asyncpg"] = removed_asyncpg From 8a817d99c218f95e3af6b8a80eeae0c0c645dc78 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 15:37:40 +0530 Subject: [PATCH 05/26] feat: add store migrations and postgres pooling --- src/authsome/server/store/database.py | 94 ++++++++++++++----- .../server/store/test_database_migrations.py | 46 ++++++++- 2 files changed, 118 insertions(+), 22 deletions(-) diff --git a/src/authsome/server/store/database.py b/src/authsome/server/store/database.py index 19c8dfdf..ca32ee07 100644 --- a/src/authsome/server/store/database.py +++ b/src/authsome/server/store/database.py @@ -23,12 +23,29 @@ class StoreDatabaseConfig: home: Path +@dataclass(frozen=True) +class StoreMigration: + version: int + statements: tuple[str, ...] + + +def build_migrations(backend: StoreBackend) -> list[StoreMigration]: + return [StoreMigration(version=1, statements=tuple(build_schema(backend)))] + + class StoreDatabase: """Small async database adapter shared by Store repositories.""" - def __init__(self, *, config: StoreDatabaseConfig, connection: Any) -> None: + def __init__( + self, + *, + config: StoreDatabaseConfig, + connection: Any | None = None, + pool: Any | None = None, + ) -> None: self.config = config self._connection = connection + self._pool = pool @property def backend(self) -> StoreBackend: @@ -47,30 +64,49 @@ def _sql(self, sql: str) -> str: parts.append(char) return "".join(parts) + @asynccontextmanager + async def _postgres_connection(self) -> AsyncIterator[Any]: + if self._pool is not None: + async with self._pool.acquire() as connection: + yield connection + return + if self._connection is None: + raise RuntimeError("Postgres Store connection is not configured") + yield self._connection + async def fetch_one(self, sql: str, params: Sequence[Any] = ()) -> dict[str, Any] | None: if self.backend == "sqlite": - cursor = await self._connection.execute(sql, params) + connection = self._connection + assert connection is not None + cursor = await connection.execute(sql, params) row = await cursor.fetchone() await cursor.close() return dict(row) if row is not None else None - row = await self._connection.fetchrow(self._sql(sql), *params) - return dict(row) if row is not None else None + async with self._postgres_connection() as connection: + row = await connection.fetchrow(self._sql(sql), *params) + return dict(row) if row is not None else None async def fetch_all(self, sql: str, params: Sequence[Any] = ()) -> list[dict[str, Any]]: if self.backend == "sqlite": - cursor = await self._connection.execute(sql, params) + connection = self._connection + assert connection is not None + cursor = await connection.execute(sql, params) rows = await cursor.fetchall() await cursor.close() return [dict(row) for row in rows] - rows = await self._connection.fetch(self._sql(sql), *params) - return [dict(row) for row in rows] + async with self._postgres_connection() as connection: + rows = await connection.fetch(self._sql(sql), *params) + return [dict(row) for row in rows] async def execute(self, sql: str, params: Sequence[Any] = ()) -> None: if self.backend == "sqlite": - await self._connection.execute(sql, params) - await self._connection.commit() + connection = self._connection + assert connection is not None + await connection.execute(sql, params) + await connection.commit() return - await self._connection.execute(self._sql(sql), *params) + async with self._postgres_connection() as connection: + await connection.execute(self._sql(sql), *params) async def execute_many(self, statements: Sequence[str]) -> None: for statement in statements: @@ -79,16 +115,18 @@ async def execute_many(self, statements: Sequence[str]) -> None: @asynccontextmanager async def transaction(self) -> AsyncIterator[None]: if self.backend == "sqlite": - await self._connection.execute("BEGIN") + connection = self._connection + assert connection is not None + await connection.execute("BEGIN") try: yield except Exception: - await self._connection.rollback() + await connection.rollback() raise else: - await self._connection.commit() + await connection.commit() return - async with self._connection.transaction(): + async with self._postgres_connection() as connection, connection.transaction(): yield async def is_healthy(self) -> bool: @@ -99,7 +137,11 @@ async def is_healthy(self) -> bool: return False async def close(self) -> None: - await self._connection.close() + if self._pool is not None: + await self._pool.close() + return + if self._connection is not None: + await self._connection.close() def resolve_store_database_config(home: Path | None = None, database_url: str | None = None) -> StoreDatabaseConfig: @@ -137,8 +179,13 @@ async def open_store_database(config: StoreDatabaseConfig) -> StoreDatabase: except ImportError as exc: raise RuntimeError("Postgres Store requires installing authsome[postgres]") from exc - connection = await asyncpg.connect(config.dsn) - database = StoreDatabase(config=config, connection=connection) + server_config = get_server_config(config.home) + pool = await asyncpg.create_pool( + config.dsn, + min_size=server_config.postgres_pool_min_size, + max_size=server_config.postgres_pool_max_size, + ) + database = StoreDatabase(config=config, pool=pool) await initialize_schema(database) return database @@ -153,9 +200,6 @@ def build_schema(backend: StoreBackend) -> list[str]: true_predicate = "1" return [ - "CREATE TABLE IF NOT EXISTS store_schema_version (version INTEGER PRIMARY KEY)", - "INSERT INTO store_schema_version (version) SELECT 1 " - "WHERE NOT EXISTS (SELECT 1 FROM store_schema_version WHERE version = 1)", "CREATE TABLE IF NOT EXISTS identity_registrations (" "handle TEXT PRIMARY KEY, did TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL" ")", @@ -203,7 +247,15 @@ def build_schema(backend: StoreBackend) -> list[str]: async def initialize_schema(database: StoreDatabase) -> None: - await database.execute_many(build_schema(database.backend)) + await database.execute("CREATE TABLE IF NOT EXISTS store_schema_version (version INTEGER PRIMARY KEY)") + applied_rows = await database.fetch_all("SELECT version FROM store_schema_version") + applied = {int(row["version"]) for row in applied_rows} + for migration in build_migrations(database.backend): + if migration.version in applied: + continue + for statement in migration.statements: + await database.execute(statement) + await database.execute("INSERT INTO store_schema_version (version) VALUES (?)", [migration.version]) async def create_server_store(home: Path | None = None, database_url: str | None = None): diff --git a/tests/server/store/test_database_migrations.py b/tests/server/store/test_database_migrations.py index 46f72d54..6dec2ff1 100644 --- a/tests/server/store/test_database_migrations.py +++ b/tests/server/store/test_database_migrations.py @@ -1,9 +1,15 @@ import builtins import sys +from pathlib import Path import pytest -from authsome.server.store.database import StoreDatabaseConfig, open_store_database +from authsome.server.store.database import ( + StoreDatabaseConfig, + build_migrations, + open_store_database, + resolve_store_database_config, +) @pytest.mark.asyncio @@ -25,3 +31,41 @@ def fake_import(name: str, *args, **kwargs): finally: if removed_asyncpg is not None: sys.modules["asyncpg"] = removed_asyncpg + + +@pytest.mark.asyncio +async def test_sqlite_migrations_create_schema_version(tmp_path: Path) -> None: + config = resolve_store_database_config(home=tmp_path) + database = await open_store_database(config) + + try: + row = await database.fetch_one("SELECT version FROM store_schema_version") + finally: + await database.close() + + assert row == {"version": len(build_migrations("sqlite"))} + + +@pytest.mark.asyncio +async def test_sqlite_migrations_are_idempotent(tmp_path: Path) -> None: + config = resolve_store_database_config(home=tmp_path) + first = await open_store_database(config) + await first.close() + + second = await open_store_database(config) + try: + row = await second.fetch_one("SELECT COUNT(*) AS count FROM store_schema_version") + finally: + await second.close() + + assert row == {"count": 1} + + +def test_postgres_url_uses_postgres_backend(tmp_path: Path) -> None: + config = resolve_store_database_config( + home=tmp_path, + database_url="postgresql://authsome:authsome@localhost:5432/authsome", + ) + + assert config.backend == "postgres" + assert config.dsn.startswith("postgresql://") From 9c141f3c81c1412ee51a1f3cd1a7e967dbc68af7 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 15:42:16 +0530 Subject: [PATCH 06/26] fix: bind store transactions to pooled connection --- src/authsome/server/store/database.py | 24 +++++- .../server/store/test_database_migrations.py | 85 ++++++++++++++++++- 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/authsome/server/store/database.py b/src/authsome/server/store/database.py index ca32ee07..7dff31c7 100644 --- a/src/authsome/server/store/database.py +++ b/src/authsome/server/store/database.py @@ -2,6 +2,7 @@ from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager +from contextvars import ContextVar from dataclasses import dataclass from pathlib import Path from typing import Any, Literal @@ -46,6 +47,7 @@ def __init__( self.config = config self._connection = connection self._pool = pool + self._transaction_connection: ContextVar[Any | None] = ContextVar("store_transaction_connection", default=None) @property def backend(self) -> StoreBackend: @@ -66,6 +68,10 @@ def _sql(self, sql: str) -> str: @asynccontextmanager async def _postgres_connection(self) -> AsyncIterator[Any]: + connection = self._transaction_connection.get() + if connection is not None: + yield connection + return if self._pool is not None: async with self._pool.acquire() as connection: yield connection @@ -126,7 +132,23 @@ async def transaction(self) -> AsyncIterator[None]: else: await connection.commit() return - async with self._postgres_connection() as connection, connection.transaction(): + connection = self._transaction_connection.get() + if connection is not None: + async with connection.transaction(): + yield + return + if self._pool is not None: + async with self._pool.acquire() as connection: + token = self._transaction_connection.set(connection) + try: + async with connection.transaction(): + yield + finally: + self._transaction_connection.reset(token) + return + if self._connection is None: + raise RuntimeError("Postgres Store connection is not configured") + async with self._connection.transaction(): yield async def is_healthy(self) -> bool: diff --git a/tests/server/store/test_database_migrations.py b/tests/server/store/test_database_migrations.py index 6dec2ff1..4a658c05 100644 --- a/tests/server/store/test_database_migrations.py +++ b/tests/server/store/test_database_migrations.py @@ -5,6 +5,7 @@ import pytest from authsome.server.store.database import ( + StoreDatabase, StoreDatabaseConfig, build_migrations, open_store_database, @@ -39,11 +40,11 @@ async def test_sqlite_migrations_create_schema_version(tmp_path: Path) -> None: database = await open_store_database(config) try: - row = await database.fetch_one("SELECT version FROM store_schema_version") + row = await database.fetch_one("SELECT MAX(version) AS version FROM store_schema_version") finally: await database.close() - assert row == {"version": len(build_migrations("sqlite"))} + assert row == {"version": max(migration.version for migration in build_migrations("sqlite"))} @pytest.mark.asyncio @@ -69,3 +70,83 @@ def test_postgres_url_uses_postgres_backend(tmp_path: Path) -> None: assert config.backend == "postgres" assert config.dsn.startswith("postgresql://") + + +class _FakeTransaction: + def __init__(self, connection) -> None: + self._connection = connection + + async def __aenter__(self): + self._connection.transaction_enters += 1 + return self._connection + + async def __aexit__(self, exc_type, exc, tb): + self._connection.transaction_exits += 1 + return False + + +class _FakeConnection: + def __init__(self) -> None: + self.execute_calls: list[tuple[str, tuple[object, ...]]] = [] + self.fetchrow_calls: list[tuple[str, tuple[object, ...]]] = [] + self.fetch_calls: list[tuple[str, tuple[object, ...]]] = [] + self.transaction_enters = 0 + self.transaction_exits = 0 + + def transaction(self) -> _FakeTransaction: + return _FakeTransaction(self) + + async def execute(self, sql: str, *params: object): + self.execute_calls.append((sql, params)) + + async def fetchrow(self, sql: str, *params: object): + self.fetchrow_calls.append((sql, params)) + + async def fetch(self, sql: str, *params: object): + self.fetch_calls.append((sql, params)) + return [] + + async def close(self) -> None: + return None + + +class _FakeAcquire: + def __init__(self, pool, connection) -> None: + self._pool = pool + self._connection = connection + + async def __aenter__(self): + self._pool.acquire_count += 1 + return self._connection + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class _FakePool: + def __init__(self, connection) -> None: + self._connection = connection + self.acquire_count = 0 + self.close_count = 0 + + def acquire(self) -> _FakeAcquire: + return _FakeAcquire(self, self._connection) + + async def close(self) -> None: + self.close_count += 1 + + +@pytest.mark.asyncio +async def test_postgres_transaction_uses_single_pooled_connection(tmp_path: Path) -> None: + config = StoreDatabaseConfig(backend="postgres", dsn="postgresql://localhost:5432/authsome", home=tmp_path) + connection = _FakeConnection() + pool = _FakePool(connection) + db = StoreDatabase(config=config, pool=pool) + try: + async with db.transaction(): + await db.execute("INSERT INTO audit_events (event_id) VALUES (?)", ["evt_1"]) + finally: + await db.close() + + assert pool.acquire_count == 1 + assert connection.execute_calls == [("INSERT INTO audit_events (event_id) VALUES ($1)", ("evt_1",))] From fc3189bad1de6020fd052fac7333e53c565c9504 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 15:46:36 +0530 Subject: [PATCH 07/26] refactor: move pop replay cache to server --- src/authsome/identity/__init__.py | 2 -- src/authsome/identity/proof.py | 17 ------------ src/authsome/server/app.py | 4 +-- src/authsome/server/replay_cache.py | 41 +++++++++++++++++++++++++++++ src/authsome/server/routes/_deps.py | 3 ++- tests/identity/test_proof.py | 12 ++++----- tests/server/test_replay_cache.py | 25 ++++++++++++++++++ 7 files changed, 76 insertions(+), 28 deletions(-) create mode 100644 src/authsome/server/replay_cache.py create mode 100644 tests/server/test_replay_cache.py diff --git a/src/authsome/identity/__init__.py b/src/authsome/identity/__init__.py index d1a22007..69fd2a49 100644 --- a/src/authsome/identity/__init__.py +++ b/src/authsome/identity/__init__.py @@ -20,7 +20,6 @@ POP_AUTH_SCHEME, ProofClaims, ProofValidationError, - ReplayCache, create_proof_jwt, validate_proof_jwt, ) @@ -36,7 +35,6 @@ "POP_AUTH_SCHEME", "ProofClaims", "ProofValidationError", - "ReplayCache", "create_identity_material", "create_proof_jwt", "generate_handle", diff --git a/src/authsome/identity/proof.py b/src/authsome/identity/proof.py index b2b0bf75..6820aac0 100644 --- a/src/authsome/identity/proof.py +++ b/src/authsome/identity/proof.py @@ -28,20 +28,6 @@ class ProofClaims: jwt_id: str -class ReplayCache: - """Small in-memory jti replay cache.""" - - def __init__(self) -> None: - self._seen: dict[str, int] = {} - - def check_and_store(self, jti: str, exp: int) -> None: - now = int(time.time()) - self._seen = {key: value for key, value in self._seen.items() if value > now} - if jti in self._seen: - raise ProofValidationError("Proof JWT was already used") - self._seen[jti] = exp - - def body_sha256(body: bytes) -> str: return hashlib.sha256(body).hexdigest() @@ -78,7 +64,6 @@ def validate_proof_jwt( # noqa: PLR0913 method: str, path_query: str, body: bytes, - replay_cache: ReplayCache | None = None, audience: str = DEFAULT_AUDIENCE, ) -> ProofClaims: unverified = _unverified_claims(token) @@ -101,8 +86,6 @@ def validate_proof_jwt( # noqa: PLR0913 exp = claims.get("exp") if not isinstance(exp, int): raise ProofValidationError("Proof JWT exp must be an integer") - if replay_cache is not None: - replay_cache.check_and_store(jwt_id, exp) return ProofClaims(issuer=issuer, subject=subject, expires_at=exp, jwt_id=jwt_id) diff --git a/src/authsome/server/app.py b/src/authsome/server/app.py index ac9b9168..8b084e3d 100644 --- a/src/authsome/server/app.py +++ b/src/authsome/server/app.py @@ -9,7 +9,6 @@ from authsome.auth.sessions import AuthSessionStore from authsome.errors import AuthsomeError -from authsome.identity.proof import ReplayCache from authsome.server.analytics import init_posthog, shutdown_posthog from authsome.server.dependencies import ( create_account_auth_service, @@ -21,6 +20,7 @@ load_server_config, ) from authsome.server.provider_repository import ProviderRepository +from authsome.server.replay_cache import MemoryReplayCache from authsome.server.routes.audit import router as audit_router from authsome.server.routes.auth import browser_router as auth_browser_router from authsome.server.routes.auth import router as auth_router @@ -46,7 +46,7 @@ async def lifespan(app: FastAPI): app.state.vault = await create_vault(app.state.store.home) app.state.auth_sessions = AuthSessionStore() app.state.ui_sessions = UiSessionStore(load_ui_session_signing_secret(app.state.store.home)) - app.state.proof_replay_cache = ReplayCache() + app.state.proof_replay_cache = MemoryReplayCache() app.state.provider_repository = ProviderRepository(app.state.store.provider_definitions) app.state.account_auth_service = create_account_auth_service(app.state.store, app.state.ui_sessions) app.state.server_base_url = get_server_base_url() diff --git a/src/authsome/server/replay_cache.py b/src/authsome/server/replay_cache.py new file mode 100644 index 00000000..50c0945d --- /dev/null +++ b/src/authsome/server/replay_cache.py @@ -0,0 +1,41 @@ +"""Server-owned PoP replay caches.""" + +import time +from typing import Protocol + +from authsome.identity.proof import ProofValidationError + + +class ReplayCache(Protocol): + async def check_and_store(self, jti: str, exp: int) -> None: + """Store a JTI until expiry or raise when it has already been used.""" + + +class MemoryReplayCache: + """Process-local replay cache for local development and tests.""" + + def __init__(self) -> None: + self._seen: dict[str, int] = {} + + async def check_and_store(self, jti: str, exp: int) -> None: + now = int(time.time()) + self._seen = {key: value for key, value in self._seen.items() if value > now} + if jti in self._seen: + raise ProofValidationError("Proof JWT was already used") + if exp > now: + self._seen[jti] = exp + + +class RedisReplayCache: + """Redis-backed replay cache shared across server replicas.""" + + def __init__(self, client, *, key_prefix: str = "authsome:pop:jti") -> None: + self._client = client + self._key_prefix = key_prefix.rstrip(":") + + async def check_and_store(self, jti: str, exp: int) -> None: + ttl = max(exp - int(time.time()), 1) + key = f"{self._key_prefix}:{jti}" + stored = await self._client.set(key, "1", ex=ttl, nx=True) + if not stored: + raise ProofValidationError("Proof JWT was already used") diff --git a/src/authsome/server/routes/_deps.py b/src/authsome/server/routes/_deps.py index 5e3f625c..929383af 100644 --- a/src/authsome/server/routes/_deps.py +++ b/src/authsome/server/routes/_deps.py @@ -143,11 +143,12 @@ async def verify_pop_caller(request: Request) -> ResolvedOwnership: method=request.method, path_query=path_query, body=body, - replay_cache=request.app.state.proof_replay_cache, ) except (ProofValidationError, ValueError) as exc: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc + await request.app.state.proof_replay_cache.check_and_store(claims.jwt_id, claims.expires_at) + registration = await request.app.state.store.identity_registry.resolve(claims.subject) if registration is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unknown identity handle") diff --git a/tests/identity/test_proof.py b/tests/identity/test_proof.py index 6ef8106e..d5eed978 100644 --- a/tests/identity/test_proof.py +++ b/tests/identity/test_proof.py @@ -3,7 +3,7 @@ import pytest from authsome.cli.identity import RuntimeIdentity -from authsome.identity.proof import ReplayCache, create_proof_jwt, validate_proof_jwt +from authsome.identity.proof import create_proof_jwt, validate_proof_jwt def _token(tmp_path: Path, *, method: str = "POST", path: str = "/connections", body: bytes = b"{}") -> str: @@ -42,10 +42,10 @@ def test_validate_proof_jwt_rejects_wrong_body(tmp_path: Path) -> None: validate_proof_jwt(token=token, method="POST", path_query="/connections", body=b'{"x":1}') -def test_validate_proof_jwt_rejects_replay(tmp_path: Path) -> None: +def test_validate_proof_jwt_returns_jti_for_server_replay_check(tmp_path: Path) -> None: token = _token(tmp_path) - cache = ReplayCache() - validate_proof_jwt(token=token, method="POST", path_query="/connections", body=b"{}", replay_cache=cache) - with pytest.raises(ValueError, match="already used"): - validate_proof_jwt(token=token, method="POST", path_query="/connections", body=b"{}", replay_cache=cache) + claims = validate_proof_jwt(token=token, method="POST", path_query="/connections", body=b"{}") + + assert claims.jwt_id + assert claims.expires_at > 0 diff --git a/tests/server/test_replay_cache.py b/tests/server/test_replay_cache.py new file mode 100644 index 00000000..8cab16a9 --- /dev/null +++ b/tests/server/test_replay_cache.py @@ -0,0 +1,25 @@ +import time + +import pytest + +from authsome.identity.proof import ProofValidationError +from authsome.server.replay_cache import MemoryReplayCache + + +@pytest.mark.asyncio +async def test_memory_replay_cache_rejects_duplicate_jti() -> None: + cache = MemoryReplayCache() + exp = int(time.time()) + 60 + + await cache.check_and_store("jti-1", exp) + + with pytest.raises(ProofValidationError, match="already used"): + await cache.check_and_store("jti-1", exp) + + +@pytest.mark.asyncio +async def test_memory_replay_cache_drops_expired_entries() -> None: + cache = MemoryReplayCache() + + await cache.check_and_store("jti-1", int(time.time()) - 1) + await cache.check_and_store("jti-1", int(time.time()) + 60) From c68de27a155cacde3a21bef6e446857921a4d7f4 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 15:48:34 +0530 Subject: [PATCH 08/26] fix: preserve pop replay auth errors --- src/authsome/server/routes/_deps.py | 3 +-- tests/server/test_pop_auth.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/authsome/server/routes/_deps.py b/src/authsome/server/routes/_deps.py index 929383af..aad79a96 100644 --- a/src/authsome/server/routes/_deps.py +++ b/src/authsome/server/routes/_deps.py @@ -144,11 +144,10 @@ async def verify_pop_caller(request: Request) -> ResolvedOwnership: path_query=path_query, body=body, ) + await request.app.state.proof_replay_cache.check_and_store(claims.jwt_id, claims.expires_at) except (ProofValidationError, ValueError) as exc: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc - await request.app.state.proof_replay_cache.check_and_store(claims.jwt_id, claims.expires_at) - registration = await request.app.state.store.identity_registry.resolve(claims.subject) if registration is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unknown identity handle") diff --git a/tests/server/test_pop_auth.py b/tests/server/test_pop_auth.py index e93e84ac..2e0fb2e1 100644 --- a/tests/server/test_pop_auth.py +++ b/tests/server/test_pop_auth.py @@ -86,6 +86,22 @@ def test_whoami_accepts_valid_pop_and_scopes_identity(monkeypatch, tmp_path: Pat assert "Argon2id" in response.json()["encryption_backend"] +def test_whoami_rejects_replayed_pop_jwt(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + monkeypatch.setenv("AUTHSOME_MASTER_KEY", base64.b64encode(b"\x03" * 32).decode("ascii")) + + with create_server_test_client() as client: + register_and_claim_identity(client, tmp_path, "steady-wisely-boldly-0042") + headers = _auth_header(tmp_path, "GET", "/api/whoami") + + first_response = client.get("/api/whoami", headers=headers) + second_response = client.get("/api/whoami", headers=headers) + + assert first_response.status_code == status.HTTP_200_OK + assert second_response.status_code == status.HTTP_401_UNAUTHORIZED + assert second_response.json()["detail"] == "Proof JWT was already used" + + def test_health_and_ready_report_encryption_details(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) monkeypatch.setenv("AUTHSOME_MASTER_KEY", base64.b64encode(b"\x02" * 32).decode("ascii")) From 604d18e24a4682aa8330ba0a5c2dd925344c2af5 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 15:54:34 +0530 Subject: [PATCH 09/26] feat: add auth session store contract --- src/authsome/auth/sessions.py | 31 +++++++- src/authsome/server/auth_sessions.py | 86 +++++++++++++++++++++++ src/authsome/server/routes/_deps.py | 4 +- src/authsome/server/routes/auth.py | 24 +++---- src/authsome/server/routes/ui.py | 4 +- tests/auth/test_session_store_contract.py | 43 ++++++++++++ 6 files changed, 174 insertions(+), 18 deletions(-) create mode 100644 src/authsome/server/auth_sessions.py create mode 100644 tests/auth/test_session_store_contract.py diff --git a/src/authsome/auth/sessions.py b/src/authsome/auth/sessions.py index c7aaa4bc..9b4f0625 100644 --- a/src/authsome/auth/sessions.py +++ b/src/authsome/auth/sessions.py @@ -3,7 +3,7 @@ import uuid from datetime import datetime, timedelta from enum import StrEnum -from typing import Any +from typing import Any, Protocol, runtime_checkable from pydantic import BaseModel, Field @@ -48,7 +48,31 @@ def is_expired(self) -> bool: return utc_now() >= self.expires_at -class AuthSessionStore: +@runtime_checkable +class AuthSessionRepository(Protocol): + async def create( # noqa: PLR0913 + self, + *, + provider: str, + identity: str | None, + principal_id: str | None, + connection_name: str, + flow_type: str, + ttl_seconds: int = DEFAULT_SESSION_TTL_SECONDS, + ) -> AuthSession: ... + + async def get(self, session_id: str) -> AuthSession: ... + + async def save(self, session: AuthSession) -> None: ... + + async def delete(self, session_id: str) -> None: ... + + async def index_oauth_state(self, session: AuthSession) -> None: ... + + async def get_by_oauth_state(self, state: str) -> AuthSession: ... + + +class MemoryAuthSessionStore: """In-memory auth session state for the daemon process.""" def __init__(self) -> None: @@ -127,3 +151,6 @@ def cleanup_expired(self) -> None: oauth_state = session.payload.get("internal_state") if oauth_state: self._state_index.pop(str(oauth_state), None) + + +AuthSessionStore = MemoryAuthSessionStore diff --git a/src/authsome/server/auth_sessions.py b/src/authsome/server/auth_sessions.py new file mode 100644 index 00000000..2ede4076 --- /dev/null +++ b/src/authsome/server/auth_sessions.py @@ -0,0 +1,86 @@ +"""Server-owned Redis auth session storage.""" + +import uuid +from datetime import timedelta +from typing import Any + +from authsome.auth.sessions import DEFAULT_SESSION_TTL_SECONDS, AuthSession +from authsome.utils import utc_now + + +class RedisAuthSessionStore: + """Redis-backed auth session store shared across server replicas.""" + + def __init__(self, client: Any, *, key_prefix: str = "authsome:auth-session") -> None: + self._client = client + self._key_prefix = key_prefix.rstrip(":") + + def _session_key(self, session_id: str) -> str: + return f"{self._key_prefix}:session:{session_id}" + + def _state_key(self, state: str) -> str: + return f"{self._key_prefix}:oauth-state:{state}" + + async def create( # noqa: PLR0913 + self, + *, + provider: str, + identity: str | None, + principal_id: str | None, + connection_name: str, + flow_type: str, + ttl_seconds: int = DEFAULT_SESSION_TTL_SECONDS, + ) -> AuthSession: + session = AuthSession( + session_id=f"sess_{uuid.uuid4().hex[:12]}", + provider=provider, + identity=identity, + principal_id=principal_id, + connection_name=connection_name, + flow_type=flow_type, + expires_at=utc_now() + timedelta(seconds=ttl_seconds), + ) + await self.save(session) + return session + + async def get(self, session_id: str) -> AuthSession: + raw = await self._client.get(self._session_key(session_id)) + if raw is None: + raise KeyError(f"Session not found: {session_id}") + session = AuthSession.model_validate_json(raw) + if session.is_expired: + await self.delete(session_id) + raise KeyError(f"Session expired: {session_id}") + return session + + async def save(self, session: AuthSession) -> None: + session.updated_at = utc_now() + ttl = max(int((session.expires_at - utc_now()).total_seconds()), 1) + await self._client.set(self._session_key(session.session_id), session.model_dump_json(), ex=ttl) + oauth_state = session.payload.get("internal_state") + if oauth_state: + await self._client.set(self._state_key(str(oauth_state)), session.session_id, ex=ttl) + + async def delete(self, session_id: str) -> None: + raw = await self._client.get(self._session_key(session_id)) + if raw is None: + await self._client.delete(self._session_key(session_id)) + return + + session = AuthSession.model_validate_json(raw) + keys = [self._session_key(session_id)] + oauth_state = session.payload.get("internal_state") + if oauth_state: + keys.append(self._state_key(str(oauth_state))) + await self._client.delete(*keys) + + async def index_oauth_state(self, session: AuthSession) -> None: + await self.save(session) + + async def get_by_oauth_state(self, state: str) -> AuthSession: + session_id = await self._client.get(self._state_key(state)) + if session_id is None: + raise KeyError(f"Session not found for OAuth state: {state}") + if isinstance(session_id, bytes): + session_id = session_id.decode() + return await self.get(str(session_id)) diff --git a/src/authsome/server/routes/_deps.py b/src/authsome/server/routes/_deps.py index aad79a96..cc9aafae 100644 --- a/src/authsome/server/routes/_deps.py +++ b/src/authsome/server/routes/_deps.py @@ -5,7 +5,7 @@ from fastapi import Depends, HTTPException, Request, status -from authsome.auth.sessions import AuthSession, AuthSessionStore +from authsome.auth.sessions import AuthSession, AuthSessionRepository from authsome.identity.principal import PrincipalRole from authsome.identity.proof import POP_AUTH_SCHEME, ProofValidationError, validate_proof_jwt from authsome.server.credential_repository import CredentialRepository @@ -206,7 +206,7 @@ def get_vault_registry(request: Request) -> VaultRegistry: return request.app.state.store.vaults -def get_auth_sessions(request: Request) -> AuthSessionStore: +def get_auth_sessions(request: Request) -> AuthSessionRepository: return request.app.state.auth_sessions diff --git a/src/authsome/server/routes/auth.py b/src/authsome/server/routes/auth.py index fb34dc9b..ffdea8be 100644 --- a/src/authsome/server/routes/auth.py +++ b/src/authsome/server/routes/auth.py @@ -7,7 +7,7 @@ from authsome.auth.input_provider import InputField from authsome.auth.models.enums import AuthType, FlowType -from authsome.auth.sessions import AuthSession, AuthSessionStatus, AuthSessionStore +from authsome.auth.sessions import AuthSession, AuthSessionRepository, AuthSessionStatus from authsome.server.analytics import capture_event from authsome.server.credential_service import CredentialService from authsome.server.routes._deps import ( @@ -43,7 +43,7 @@ async def _ensure_browser_session_identity(request: Request, session: AuthSessio return getattr(request.state, "ui_principal_id", None) == session.principal_id -async def _load_session_or_404(sessions: AuthSessionStore, session_id: str) -> AuthSession: +async def _load_session_or_404(sessions: AuthSessionRepository, session_id: str) -> AuthSession: """Return an auth session or raise the route-level not-found response.""" try: return await sessions.get(session_id) @@ -85,7 +85,7 @@ async def start_session( body: StartAuthSessionRequest, background_tasks: BackgroundTasks, auth: CredentialService = Depends(get_protected_auth_service), - sessions: AuthSessionStore = Depends(get_auth_sessions), + sessions: AuthSessionRepository = Depends(get_auth_sessions), server_base_url: str = Depends(get_server_base_url), ) -> AuthSessionResponse: definition = await auth.get_provider(body.provider) @@ -140,7 +140,7 @@ async def start_session( async def get_session( session_id: str, auth: CredentialService = Depends(get_protected_auth_service), - sessions: AuthSessionStore = Depends(get_auth_sessions), + sessions: AuthSessionRepository = Depends(get_auth_sessions), server_base_url: str = Depends(get_server_base_url), ) -> AuthSessionResponse: session = await _load_session_or_404(sessions, session_id) @@ -154,7 +154,7 @@ async def resume_session( session_id: str, body: ResumeAuthSessionRequest, auth: CredentialService = Depends(get_protected_auth_service), - sessions: AuthSessionStore = Depends(get_auth_sessions), + sessions: AuthSessionRepository = Depends(get_auth_sessions), server_base_url: str = Depends(get_server_base_url), ) -> AuthSessionResponse: session = await _load_session_or_404(sessions, session_id) @@ -177,7 +177,7 @@ async def resume_session( @router.get("/callback/oauth") async def oauth_callback( request: Request, - sessions: AuthSessionStore = Depends(get_auth_sessions), + sessions: AuthSessionRepository = Depends(get_auth_sessions), server_base_url: str = Depends(get_server_base_url), ) -> Response: state = request.query_params.get("state") @@ -217,7 +217,7 @@ async def oauth_callback( async def get_session_input( session_id: str, request: Request, - sessions: AuthSessionStore = Depends(get_auth_sessions), + sessions: AuthSessionRepository = Depends(get_auth_sessions), server_base_url: str = Depends(get_server_base_url), ) -> Any: try: @@ -254,7 +254,7 @@ async def get_session_input( async def get_session_device_code( session_id: str, request: Request, - sessions: AuthSessionStore = Depends(get_auth_sessions), + sessions: AuthSessionRepository = Depends(get_auth_sessions), ) -> Any: try: session = await sessions.get(session_id) @@ -287,7 +287,7 @@ async def get_session_device_code( async def get_browser_session_status( session_id: str, request: Request, - sessions: AuthSessionStore = Depends(get_auth_sessions), + sessions: AuthSessionRepository = Depends(get_auth_sessions), ) -> Any: try: session = await sessions.get(session_id) @@ -310,7 +310,7 @@ async def submit_input( session_id: str, request: Request, background_tasks: BackgroundTasks, - sessions: AuthSessionStore = Depends(get_auth_sessions), + sessions: AuthSessionRepository = Depends(get_auth_sessions), server_base_url: str = Depends(get_server_base_url), ): return await _submit_session_input( @@ -327,7 +327,7 @@ async def submit_browser_input( request: Request, background_tasks: BackgroundTasks, session: str, - sessions: AuthSessionStore = Depends(get_auth_sessions), + sessions: AuthSessionRepository = Depends(get_auth_sessions), server_base_url: str = Depends(get_server_base_url), ): return await _submit_session_input( @@ -344,7 +344,7 @@ async def _submit_session_input( # noqa: PLR0911 session_id: str, request: Request, background_tasks: BackgroundTasks, - sessions: AuthSessionStore, + sessions: AuthSessionRepository, server_base_url: str, ): try: diff --git a/src/authsome/server/routes/ui.py b/src/authsome/server/routes/ui.py index 058dd87c..68d41127 100644 --- a/src/authsome/server/routes/ui.py +++ b/src/authsome/server/routes/ui.py @@ -10,7 +10,7 @@ from authsome import audit from authsome.auth.models.enums import FlowType -from authsome.auth.sessions import AuthSessionStore +from authsome.auth.sessions import AuthSessionRepository from authsome.server.analytics import capture_event from authsome.server.credential_service import CredentialService from authsome.server.routes._deps import ( @@ -108,7 +108,7 @@ async def connect_provider( # noqa: PLR0913 request: Request, background_tasks: BackgroundTasks, auth: CredentialService = Depends(require_ui_auth("/")), - sessions: AuthSessionStore = Depends(get_auth_sessions), + sessions: AuthSessionRepository = Depends(get_auth_sessions), server_base_url: str = Depends(get_server_base_url), ) -> Response: """Start a provider connection from the static dashboard.""" diff --git a/tests/auth/test_session_store_contract.py b/tests/auth/test_session_store_contract.py new file mode 100644 index 00000000..75f0c9f4 --- /dev/null +++ b/tests/auth/test_session_store_contract.py @@ -0,0 +1,43 @@ +import pytest + +from authsome.auth.models.enums import FlowType +from authsome.auth.sessions import AuthSessionStatus, MemoryAuthSessionStore + + +@pytest.mark.asyncio +async def test_memory_session_store_create_get_save_and_delete() -> None: + store = MemoryAuthSessionStore() + session = await store.create( + provider="github", + identity="agent-1", + principal_id="principal_1", + connection_name="default", + flow_type=FlowType.PKCE.value, + ) + + loaded = await store.get(session.session_id) + loaded.state = AuthSessionStatus.WAITING_FOR_USER + await store.save(loaded) + + assert (await store.get(session.session_id)).state == AuthSessionStatus.WAITING_FOR_USER + + await store.delete(session.session_id) + with pytest.raises(KeyError): + await store.get(session.session_id) + + +@pytest.mark.asyncio +async def test_memory_session_store_indexes_oauth_state() -> None: + store = MemoryAuthSessionStore() + session = await store.create( + provider="github", + identity="agent-1", + principal_id="principal_1", + connection_name="default", + flow_type=FlowType.PKCE.value, + ) + session.payload["internal_state"] = "state-123" + + await store.index_oauth_state(session) + + assert (await store.get_by_oauth_state("state-123")).session_id == session.session_id From 60e21e1ceb1ab7611fa02edd576cb0e5f4b11a18 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 15:58:41 +0530 Subject: [PATCH 10/26] fix: clean redis auth session indexes --- src/authsome/server/auth_sessions.py | 18 +++- tests/auth/test_session_store_contract.py | 6 +- tests/server/test_redis_auth_sessions.py | 114 ++++++++++++++++++++++ 3 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 tests/server/test_redis_auth_sessions.py diff --git a/src/authsome/server/auth_sessions.py b/src/authsome/server/auth_sessions.py index 2ede4076..a2b68597 100644 --- a/src/authsome/server/auth_sessions.py +++ b/src/authsome/server/auth_sessions.py @@ -21,6 +21,9 @@ def _session_key(self, session_id: str) -> str: def _state_key(self, state: str) -> str: return f"{self._key_prefix}:oauth-state:{state}" + def _session_state_key(self, session_id: str) -> str: + return f"{self._key_prefix}:session-state:{session_id}" + async def create( # noqa: PLR0913 self, *, @@ -59,19 +62,28 @@ async def save(self, session: AuthSession) -> None: await self._client.set(self._session_key(session.session_id), session.model_dump_json(), ex=ttl) oauth_state = session.payload.get("internal_state") if oauth_state: - await self._client.set(self._state_key(str(oauth_state)), session.session_id, ex=ttl) + state = str(oauth_state) + await self._client.set(self._state_key(state), session.session_id, ex=ttl) + await self._client.set(self._session_state_key(session.session_id), state, ex=ttl) async def delete(self, session_id: str) -> None: raw = await self._client.get(self._session_key(session_id)) if raw is None: - await self._client.delete(self._session_key(session_id)) + state = await self._client.get(self._session_state_key(session_id)) + keys = [self._session_key(session_id), self._session_state_key(session_id)] + if state is not None: + if isinstance(state, bytes): + state = state.decode() + keys.append(self._state_key(str(state))) + await self._client.delete(*keys) return session = AuthSession.model_validate_json(raw) keys = [self._session_key(session_id)] oauth_state = session.payload.get("internal_state") if oauth_state: - keys.append(self._state_key(str(oauth_state))) + state = str(oauth_state) + keys.extend([self._state_key(state), self._session_state_key(session_id)]) await self._client.delete(*keys) async def index_oauth_state(self, session: AuthSession) -> None: diff --git a/tests/auth/test_session_store_contract.py b/tests/auth/test_session_store_contract.py index 75f0c9f4..be4df724 100644 --- a/tests/auth/test_session_store_contract.py +++ b/tests/auth/test_session_store_contract.py @@ -1,7 +1,7 @@ import pytest from authsome.auth.models.enums import FlowType -from authsome.auth.sessions import AuthSessionStatus, MemoryAuthSessionStore +from authsome.auth.sessions import AuthSessionStatus, AuthSessionStore, MemoryAuthSessionStore @pytest.mark.asyncio @@ -41,3 +41,7 @@ async def test_memory_session_store_indexes_oauth_state() -> None: await store.index_oauth_state(session) assert (await store.get_by_oauth_state("state-123")).session_id == session.session_id + + +def test_auth_session_store_alias_constructs_memory_store() -> None: + assert isinstance(AuthSessionStore(), MemoryAuthSessionStore) diff --git a/tests/server/test_redis_auth_sessions.py b/tests/server/test_redis_auth_sessions.py new file mode 100644 index 00000000..a8d7ad2b --- /dev/null +++ b/tests/server/test_redis_auth_sessions.py @@ -0,0 +1,114 @@ +import pytest + +from authsome.auth.models.enums import FlowType +from authsome.server.auth_sessions import RedisAuthSessionStore + + +class FakeRedisClient: + def __init__(self) -> None: + self.data: dict[str, str] = {} + self.ttls: dict[str, int | None] = {} + + async def get(self, key: str): + return self.data.get(key) + + async def set(self, key: str, value: str, *, ex: int | None = None, nx: bool | None = None): + if nx and key in self.data: + return False + self.data[key] = value + self.ttls[key] = ex + return True + + async def delete(self, *keys: str): + deleted = 0 + for key in keys: + if key in self.data: + deleted += 1 + self.data.pop(key, None) + self.ttls.pop(key, None) + return deleted + + +@pytest.mark.asyncio +async def test_redis_session_store_round_trips_session_json() -> None: + client = FakeRedisClient() + store = RedisAuthSessionStore(client) + + session = await store.create( + provider="github", + identity="agent-1", + principal_id="principal_1", + connection_name="default", + flow_type=FlowType.PKCE.value, + ) + session.payload["internal_state"] = "state-123" + session.status_message = "waiting" + + await store.save(session) + + loaded = await store.get(session.session_id) + assert loaded.session_id == session.session_id + assert loaded.payload["internal_state"] == "state-123" + assert loaded.status_message == "waiting" + + +@pytest.mark.asyncio +async def test_redis_session_store_indexes_oauth_state() -> None: + client = FakeRedisClient() + store = RedisAuthSessionStore(client) + session = await store.create( + provider="github", + identity="agent-1", + principal_id="principal_1", + connection_name="default", + flow_type=FlowType.PKCE.value, + ) + session.payload["internal_state"] = "state-123" + + await store.index_oauth_state(session) + + assert (await store.get_by_oauth_state("state-123")).session_id == session.session_id + + +@pytest.mark.asyncio +async def test_redis_session_store_delete_clears_session_and_state_indexes() -> None: + client = FakeRedisClient() + store = RedisAuthSessionStore(client) + session = await store.create( + provider="github", + identity="agent-1", + principal_id="principal_1", + connection_name="default", + flow_type=FlowType.PKCE.value, + ) + session.payload["internal_state"] = "state-123" + await store.save(session) + + await store.delete(session.session_id) + + assert await client.get(f"authsome:auth-session:session:{session.session_id}") is None + assert await client.get("authsome:auth-session:oauth-state:state-123") is None + assert await client.get(f"authsome:auth-session:session-state:{session.session_id}") is None + + +@pytest.mark.asyncio +async def test_redis_session_store_delete_uses_reverse_state_mapping_when_session_missing() -> None: + client = FakeRedisClient() + store = RedisAuthSessionStore(client) + session = await store.create( + provider="github", + identity="agent-1", + principal_id="principal_1", + connection_name="default", + flow_type=FlowType.PKCE.value, + ) + session.payload["internal_state"] = "state-123" + await store.save(session) + + client.data.pop(f"authsome:auth-session:session:{session.session_id}") + + await store.delete(session.session_id) + + assert await client.get(f"authsome:auth-session:session:{session.session_id}") is None + assert await client.get("authsome:auth-session:oauth-state:state-123") is None + assert await client.get(f"authsome:auth-session:session-state:{session.session_id}") is None From 0ea30dc7839ece5045db4f3e870d98a647b9945f Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 16:03:16 +0530 Subject: [PATCH 11/26] refactor: split pending claim storage --- src/authsome/server/identity_bootstrap.py | 2 +- src/authsome/server/routes/ui.py | 6 +- src/authsome/server/ui_sessions.py | 116 +++++++++++++++++++--- tests/server/test_pending_claim_store.py | 62 ++++++++++++ 4 files changed, 169 insertions(+), 17 deletions(-) create mode 100644 tests/server/test_pending_claim_store.py diff --git a/src/authsome/server/identity_bootstrap.py b/src/authsome/server/identity_bootstrap.py index 63eb5e22..51207aa8 100644 --- a/src/authsome/server/identity_bootstrap.py +++ b/src/authsome/server/identity_bootstrap.py @@ -75,7 +75,7 @@ async def get_identity_status(self, *, handle: str) -> IdentityBootstrapStatus | async def _build_status(self, registration: IdentityRegistration) -> IdentityBootstrapStatus: claim = await self._claims.resolve(registration.handle) if claim is None: - pending = self._ui_sessions.create_pending_claim(identity=registration.handle) + pending = await self._ui_sessions.create_pending_claim(identity=registration.handle) return IdentityBootstrapStatus( identity=registration.handle, did=registration.did, diff --git a/src/authsome/server/routes/ui.py b/src/authsome/server/routes/ui.py index 68d41127..f19db76a 100644 --- a/src/authsome/server/routes/ui.py +++ b/src/authsome/server/routes/ui.py @@ -204,7 +204,7 @@ async def claim_identity_page( ui_sessions: UiSessionStore = Depends(get_ui_sessions), ) -> dict[str, str | bool]: try: - pending = ui_sessions.get_pending_claim(token) + pending = await ui_sessions.get_pending_claim(token) except KeyError: return {"token": token, "identity": "", "authenticated": False, "expired": True} @@ -277,7 +277,7 @@ async def claim_identity_confirm( ui_sessions: UiSessionStore = Depends(get_ui_sessions), ) -> Response: try: - pending = ui_sessions.get_pending_claim(token) + pending = await ui_sessions.get_pending_claim(token) except KeyError: return RedirectResponse( url=f"/claim?{urlencode({'token': token, 'error': 'expired'})}", status_code=status.HTTP_303_SEE_OTHER @@ -290,7 +290,7 @@ async def claim_identity_confirm( url=f"/login?{urlencode({'next': f'/claim?token={token}'})}", status_code=status.HTTP_303_SEE_OTHER ) - pending = ui_sessions.consume_pending_claim(token) + pending = await ui_sessions.consume_pending_claim(token) await request.app.state.ownership_resolver.claim_identity_for_principal( identity=pending.identity, principal_id=principal_id, diff --git a/src/authsome/server/ui_sessions.py b/src/authsome/server/ui_sessions.py index 89408da6..807df9da 100644 --- a/src/authsome/server/ui_sessions.py +++ b/src/authsome/server/ui_sessions.py @@ -4,6 +4,7 @@ import hmac import secrets from datetime import UTC, datetime, timedelta +from typing import Any, Protocol import jwt from pydantic import BaseModel, Field @@ -33,6 +34,24 @@ def is_expired(self) -> bool: return utc_now() >= self.expires_at +class PendingClaimStore(Protocol): + """Async storage for short-lived identity claim tokens.""" + + async def create( + self, + *, + identity: str, + ttl_seconds: int = DEFAULT_UI_BOOTSTRAP_TTL_SECONDS, + ) -> PendingClaimToken: + """Create a claim token for an identity.""" + + async def get(self, token: str) -> PendingClaimToken: + """Return a claim token by value.""" + + async def consume(self, token: str) -> PendingClaimToken: + """Return and remove a claim token by value.""" + + class BrowserSession(BaseModel): """Principal-scoped browser session.""" @@ -47,14 +66,13 @@ def is_expired(self) -> bool: return utc_now() >= self.expires_at -class UiSessionStore: - """In-memory UI session helper with signed JWT cookies.""" +class MemoryPendingClaimStore: + """In-memory pending claim token store.""" - def __init__(self, signing_secret: str | bytes) -> None: - self._secret = signing_secret.encode("utf-8") if isinstance(signing_secret, str) else signing_secret + def __init__(self) -> None: self._pending_claims: dict[str, PendingClaimToken] = {} - def create_pending_claim( + async def create( self, *, identity: str, @@ -69,7 +87,7 @@ def create_pending_claim( self._pending_claims[pending.token] = pending return pending - def get_pending_claim(self, token: str) -> PendingClaimToken: + async def get(self, token: str) -> PendingClaimToken: self.cleanup_expired() pending = self._pending_claims.get(token) if pending is None or pending.is_expired: @@ -77,11 +95,88 @@ def get_pending_claim(self, token: str) -> PendingClaimToken: raise KeyError(f"Pending claim token not found: {token}") return pending - def consume_pending_claim(self, token: str) -> PendingClaimToken: - pending = self.get_pending_claim(token) + async def consume(self, token: str) -> PendingClaimToken: + pending = await self.get(token) self._pending_claims.pop(token, None) return pending + def cleanup_expired(self) -> None: + expired_claims = [token for token, pending in self._pending_claims.items() if pending.is_expired] + for token in expired_claims: + self._pending_claims.pop(token, None) + + +class RedisPendingClaimStore: + """Redis-backed pending claim token store shared across server replicas.""" + + def __init__(self, client: Any, *, key_prefix: str = "authsome:ui-session") -> None: + self._client = client + self._key_prefix = key_prefix.rstrip(":") + + def _pending_claim_key(self, token: str) -> str: + return f"{self._key_prefix}:pending-claim:{token}" + + async def create( + self, + *, + identity: str, + ttl_seconds: int = DEFAULT_UI_BOOTSTRAP_TTL_SECONDS, + ) -> PendingClaimToken: + pending = PendingClaimToken( + token=f"claim_{secrets.token_urlsafe(24)}", + identity=identity, + expires_at=utc_now() + timedelta(seconds=ttl_seconds), + ) + await self._client.set( + self._pending_claim_key(pending.token), + pending.model_dump_json(), + ex=max(int(ttl_seconds), 1), + ) + return pending + + async def get(self, token: str) -> PendingClaimToken: + raw = await self._client.get(self._pending_claim_key(token)) + if raw is None: + raise KeyError(f"Pending claim token not found: {token}") + if isinstance(raw, bytes): + raw = raw.decode() + pending = PendingClaimToken.model_validate_json(raw) + if pending.is_expired: + await self._client.delete(self._pending_claim_key(token)) + raise KeyError(f"Pending claim token not found: {token}") + return pending + + async def consume(self, token: str) -> PendingClaimToken: + pending = await self.get(token) + await self._client.delete(self._pending_claim_key(token)) + return pending + + +class UiSessionStore: + """In-memory UI session helper with signed JWT cookies.""" + + def __init__( + self, + signing_secret: str | bytes, + pending_claims: PendingClaimStore | None = None, + ) -> None: + self._secret = signing_secret.encode("utf-8") if isinstance(signing_secret, str) else signing_secret + self._pending_claim_store = pending_claims or MemoryPendingClaimStore() + + async def create_pending_claim( + self, + *, + identity: str, + ttl_seconds: int = DEFAULT_UI_BOOTSTRAP_TTL_SECONDS, + ) -> PendingClaimToken: + return await self._pending_claim_store.create(identity=identity, ttl_seconds=ttl_seconds) + + async def get_pending_claim(self, token: str) -> PendingClaimToken: + return await self._pending_claim_store.get(token) + + async def consume_pending_claim(self, token: str) -> PendingClaimToken: + return await self._pending_claim_store.consume(token) + def create_browser_session( self, *, @@ -132,11 +227,6 @@ def build_cookie_value(self, token: str) -> str: def delete_browser_session(self, cookie_value: str) -> None: self._verify_cookie(cookie_value) - def cleanup_expired(self) -> None: - expired_claims = [token for token, pending in self._pending_claims.items() if pending.is_expired] - for token in expired_claims: - self._pending_claims.pop(token, None) - def _verify_cookie(self, cookie_value: str) -> str: token, sep, signature = cookie_value.rpartition(".") if not token or not sep or not signature: diff --git a/tests/server/test_pending_claim_store.py b/tests/server/test_pending_claim_store.py new file mode 100644 index 00000000..4a0421c4 --- /dev/null +++ b/tests/server/test_pending_claim_store.py @@ -0,0 +1,62 @@ +import pytest + +from authsome.server.ui_sessions import MemoryPendingClaimStore, RedisPendingClaimStore, UiSessionStore + + +class FakeRedisClient: + def __init__(self) -> None: + self.data: dict[str, str] = {} + self.ttls: dict[str, int | None] = {} + + async def get(self, key: str): + return self.data.get(key) + + async def set(self, key: str, value: str, *, ex: int | None = None, nx: bool | None = None): + if nx and key in self.data: + return False + self.data[key] = value + self.ttls[key] = ex + return True + + async def delete(self, *keys: str): + deleted = 0 + for key in keys: + if key in self.data: + deleted += 1 + self.data.pop(key, None) + self.ttls.pop(key, None) + return deleted + + +@pytest.mark.asyncio +async def test_memory_pending_claim_store_create_get_consume() -> None: + store = MemoryPendingClaimStore() + + pending = await store.create(identity="agent-1") + + assert (await store.get(pending.token)).identity == "agent-1" + assert (await store.consume(pending.token)).identity == "agent-1" + with pytest.raises(KeyError): + await store.get(pending.token) + + +def test_ui_session_store_keeps_browser_sessions_stateless() -> None: + ui_sessions = UiSessionStore("test-secret") + + session = ui_sessions.create_browser_session(principal_id="principal_1", email="dev@example.com") + cookie = ui_sessions.build_cookie_value(session.token) + + assert ui_sessions.get_browser_session(cookie).principal_id == "principal_1" + + +@pytest.mark.asyncio +async def test_redis_pending_claim_store_consumes_once() -> None: + client = FakeRedisClient() + store = RedisPendingClaimStore(client) + + pending = await store.create(identity="agent-1") + + assert (await store.get(pending.token)).identity == "agent-1" + assert (await store.consume(pending.token)).identity == "agent-1" + with pytest.raises(KeyError): + await store.get(pending.token) From b754b80227906925670dbe4adb7844ac0be012b9 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 16:06:40 +0530 Subject: [PATCH 12/26] fix: atomically consume redis pending claims --- src/authsome/server/ui_sessions.py | 34 +++++++++++++++++------- tests/server/test_pending_claim_store.py | 23 ++++++++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/authsome/server/ui_sessions.py b/src/authsome/server/ui_sessions.py index 807df9da..ad350b02 100644 --- a/src/authsome/server/ui_sessions.py +++ b/src/authsome/server/ui_sessions.py @@ -78,13 +78,14 @@ async def create( identity: str, ttl_seconds: int = DEFAULT_UI_BOOTSTRAP_TTL_SECONDS, ) -> PendingClaimToken: - self.cleanup_expired() pending = PendingClaimToken( token=f"claim_{secrets.token_urlsafe(24)}", identity=identity, expires_at=utc_now() + timedelta(seconds=ttl_seconds), ) - self._pending_claims[pending.token] = pending + if ttl_seconds > 0: + self.cleanup_expired() + self._pending_claims[pending.token] = pending return pending async def get(self, token: str) -> PendingClaimToken: @@ -127,11 +128,12 @@ async def create( identity=identity, expires_at=utc_now() + timedelta(seconds=ttl_seconds), ) - await self._client.set( - self._pending_claim_key(pending.token), - pending.model_dump_json(), - ex=max(int(ttl_seconds), 1), - ) + if ttl_seconds > 0: + await self._client.set( + self._pending_claim_key(pending.token), + pending.model_dump_json(), + ex=max(int(ttl_seconds), 1), + ) return pending async def get(self, token: str) -> PendingClaimToken: @@ -147,8 +149,22 @@ async def get(self, token: str) -> PendingClaimToken: return pending async def consume(self, token: str) -> PendingClaimToken: - pending = await self.get(token) - await self._client.delete(self._pending_claim_key(token)) + key = self._pending_claim_key(token) + getdel = getattr(self._client, "getdel", None) + if callable(getdel): + raw = await getdel(key) + else: + # Compatibility fallback for fake clients that do not implement GETDEL. + raw = await self._client.get(key) + if raw is not None: + await self._client.delete(key) + if raw is None: + raise KeyError(f"Pending claim token not found: {token}") + if isinstance(raw, bytes): + raw = raw.decode() + pending = PendingClaimToken.model_validate_json(raw) + if pending.is_expired: + raise KeyError(f"Pending claim token not found: {token}") return pending diff --git a/tests/server/test_pending_claim_store.py b/tests/server/test_pending_claim_store.py index 4a0421c4..26a7ee89 100644 --- a/tests/server/test_pending_claim_store.py +++ b/tests/server/test_pending_claim_store.py @@ -7,10 +7,15 @@ class FakeRedisClient: def __init__(self) -> None: self.data: dict[str, str] = {} self.ttls: dict[str, int | None] = {} + self.getdel_calls: list[str] = [] async def get(self, key: str): return self.data.get(key) + async def getdel(self, key: str): + self.getdel_calls.append(key) + return self.data.pop(key, None) + async def set(self, key: str, value: str, *, ex: int | None = None, nx: bool | None = None): if nx and key in self.data: return False @@ -58,5 +63,23 @@ async def test_redis_pending_claim_store_consumes_once() -> None: assert (await store.get(pending.token)).identity == "agent-1" assert (await store.consume(pending.token)).identity == "agent-1" + assert client.getdel_calls == [f"authsome:ui-session:pending-claim:{pending.token}"] with pytest.raises(KeyError): await store.get(pending.token) + + +@pytest.mark.asyncio +async def test_zero_ttl_pending_claim_is_immediately_expired() -> None: + memory_store = MemoryPendingClaimStore() + redis_store = RedisPendingClaimStore(FakeRedisClient()) + + memory_pending = await memory_store.create(identity="agent-1", ttl_seconds=0) + redis_pending = await redis_store.create(identity="agent-1", ttl_seconds=0) + + assert memory_pending.is_expired + assert redis_pending.is_expired + + with pytest.raises(KeyError): + await memory_store.get(memory_pending.token) + with pytest.raises(KeyError): + await redis_store.get(redis_pending.token) From d828d2b775c526ef7c8ac700a0396afe0ff88d77 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 16:15:19 +0530 Subject: [PATCH 13/26] feat: select redis runtime state --- src/authsome/server/app.py | 24 ++- src/authsome/server/dependencies.py | 55 ++++++- src/authsome/server/ui_sessions.py | 2 +- .../server/test_runtime_backend_selection.py | 149 ++++++++++++++++++ 4 files changed, 218 insertions(+), 12 deletions(-) create mode 100644 tests/server/test_runtime_backend_selection.py diff --git a/src/authsome/server/app.py b/src/authsome/server/app.py index 8b084e3d..55695721 100644 --- a/src/authsome/server/app.py +++ b/src/authsome/server/app.py @@ -7,20 +7,19 @@ from fastapi.responses import JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles -from authsome.auth.sessions import AuthSessionStore from authsome.errors import AuthsomeError from authsome.server.analytics import init_posthog, shutdown_posthog from authsome.server.dependencies import ( create_account_auth_service, create_identity_bootstrap_service, create_ownership_resolver, + create_runtime_state, create_store, create_vault, get_server_base_url, load_server_config, ) from authsome.server.provider_repository import ProviderRepository -from authsome.server.replay_cache import MemoryReplayCache from authsome.server.routes.audit import router as audit_router from authsome.server.routes.auth import browser_router as auth_browser_router from authsome.server.routes.auth import router as auth_router @@ -44,9 +43,13 @@ async def lifespan(app: FastAPI): app.state.server_config = await load_server_config(app.state.store) app.state.audit_log = app.state.store.audit_events.configure_exporter() app.state.vault = await create_vault(app.state.store.home) - app.state.auth_sessions = AuthSessionStore() - app.state.ui_sessions = UiSessionStore(load_ui_session_signing_secret(app.state.store.home)) - app.state.proof_replay_cache = MemoryReplayCache() + app.state.runtime_state = await create_runtime_state() + app.state.auth_sessions = app.state.runtime_state.auth_sessions + app.state.proof_replay_cache = app.state.runtime_state.replay_cache + app.state.ui_sessions = UiSessionStore( + load_ui_session_signing_secret(app.state.store.home), + pending_claims=app.state.runtime_state.pending_claims, + ) app.state.provider_repository = ProviderRepository(app.state.store.provider_definitions) app.state.account_auth_service = create_account_auth_service(app.state.store, app.state.ui_sessions) app.state.server_base_url = get_server_base_url() @@ -60,9 +63,14 @@ async def lifespan(app: FastAPI): app.state.ownership_resolver = create_ownership_resolver(app.state.store) app.state.ownership_cache = {} yield - shutdown_posthog() - app.state.audit_log.shutdown() - await app.state.store.close() + try: + shutdown_posthog() + app.state.audit_log.shutdown() + await app.state.store.close() + finally: + runtime_state = getattr(app.state, "runtime_state", None) + if runtime_state is not None: + await runtime_state.close() def create_app() -> FastAPI: diff --git a/src/authsome/server/dependencies.py b/src/authsome/server/dependencies.py index 54d9c782..ae28fa26 100644 --- a/src/authsome/server/dependencies.py +++ b/src/authsome/server/dependencies.py @@ -1,21 +1,25 @@ """Concrete local dependency wiring for the daemon server.""" +from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import Any, cast from key_value.aio.stores.disk import DiskStore from authsome.auth.models.config import ServerConfig +from authsome.auth.sessions import MemoryAuthSessionStore from authsome.config import get_authsome_config from authsome.server.account_auth import AccountAuthService +from authsome.server.auth_sessions import RedisAuthSessionStore from authsome.server.config import get_server_config from authsome.server.identity_bootstrap import IdentityBootstrapService from authsome.server.ownership import OwnershipResolver +from authsome.server.replay_cache import MemoryReplayCache, RedisReplayCache from authsome.server.secrets import load_master_secret from authsome.server.store import ServerStore from authsome.server.store import create_server_store as _create_server_store from authsome.server.store.repositories import IdentityRegistry -from authsome.server.ui_sessions import UiSessionStore +from authsome.server.ui_sessions import MemoryPendingClaimStore, RedisPendingClaimStore, UiSessionStore from authsome.server.urls import build_server_base_url from authsome.vault import Vault from authsome.vault.crypto import AesGcmEncryptionWrapper, DekManager @@ -44,13 +48,58 @@ async def load_server_config(store: ServerStore) -> ServerConfig: async def create_vault(home: Path) -> Vault: """Create the daemon vault from an initialized application store.""" server_config = get_server_config(home) - raw_kv = DiskStore(directory=str(server_config.kv_store_dir)) + if server_config.redis_url: + try: + redis_store_module = __import__("key_value.aio.stores.redis", fromlist=["RedisStore"]) + except ImportError as exc: + raise RuntimeError("Redis vault storage requires installing authsome[redis]") from exc + + RedisStore = cast(Any, redis_store_module).RedisStore + raw_kv = RedisStore(url=server_config.redis_url) + else: + raw_kv = DiskStore(directory=str(server_config.kv_store_dir)) secret = load_master_secret(home) dek = await DekManager().load_or_create(secret, raw_kv) encrypted_kv = AesGcmEncryptionWrapper(raw_kv, dek=dek) return Vault(encrypted_kv) +@dataclass +class RuntimeState: + auth_sessions: MemoryAuthSessionStore | RedisAuthSessionStore + replay_cache: MemoryReplayCache | RedisReplayCache + pending_claims: MemoryPendingClaimStore | RedisPendingClaimStore + redis_client: Any | None = None + + async def close(self) -> None: + if self.redis_client is not None: + await self.redis_client.aclose() + + +async def create_runtime_state() -> RuntimeState: + config = get_server_config() + if not config.redis_url: + return RuntimeState( + auth_sessions=MemoryAuthSessionStore(), + replay_cache=MemoryReplayCache(), + pending_claims=MemoryPendingClaimStore(), + ) + try: + redis_module = __import__("redis.asyncio", fromlist=["Redis"]) + except ImportError as exc: + raise RuntimeError("Redis state requires installing authsome[redis]") from exc + + Redis = cast(Any, redis_module).Redis + client = Redis.from_url(config.redis_url, decode_responses=True) + await client.ping() + return RuntimeState( + auth_sessions=RedisAuthSessionStore(client), + replay_cache=RedisReplayCache(client), + pending_claims=RedisPendingClaimStore(client), + redis_client=client, + ) + + def create_account_auth_service(store: ServerStore, ui_sessions: UiSessionStore) -> AccountAuthService: return AccountAuthService( principals=store.principals, diff --git a/src/authsome/server/ui_sessions.py b/src/authsome/server/ui_sessions.py index ad350b02..3e427171 100644 --- a/src/authsome/server/ui_sessions.py +++ b/src/authsome/server/ui_sessions.py @@ -169,7 +169,7 @@ async def consume(self, token: str) -> PendingClaimToken: class UiSessionStore: - """In-memory UI session helper with signed JWT cookies.""" + """Browser session helper with pluggable pending-claim storage.""" def __init__( self, diff --git a/tests/server/test_runtime_backend_selection.py b/tests/server/test_runtime_backend_selection.py new file mode 100644 index 00000000..e1ba33d7 --- /dev/null +++ b/tests/server/test_runtime_backend_selection.py @@ -0,0 +1,149 @@ +import builtins +import sys +import types +from pathlib import Path + +import pytest + +from authsome.auth.sessions import MemoryAuthSessionStore +from authsome.server.auth_sessions import RedisAuthSessionStore +from authsome.server.config import get_server_config +from authsome.server.dependencies import create_runtime_state, create_vault +from authsome.server.replay_cache import MemoryReplayCache, RedisReplayCache +from authsome.server.ui_sessions import MemoryPendingClaimStore, RedisPendingClaimStore + + +class FakeRedisClient: + def __init__(self) -> None: + self.ping_called = False + self.aclose_called = False + + @classmethod + def from_url(cls, url: str, decode_responses: bool = False): + client = cls() + client.url = url + client.decode_responses = decode_responses + return client + + async def ping(self) -> None: + self.ping_called = True + + async def aclose(self) -> None: + self.aclose_called = True + + +def _patch_import(monkeypatch: pytest.MonkeyPatch, module_name: str, module: types.ModuleType | None) -> None: + real_import = builtins.__import__ + + def fake_import(name: str, globals=None, locals=None, fromlist=(), level=0): + if name == module_name or name.startswith(f"{module_name}."): + if module is None: + raise ImportError(module_name) + return sys.modules.get(name, module) + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + +@pytest.mark.asyncio +async def test_runtime_state_defaults_to_memory_without_redis(monkeypatch) -> None: + monkeypatch.delenv("AUTHSOME_REDIS_URL", raising=False) + get_server_config.cache_clear() + + state = await create_runtime_state() + try: + assert isinstance(state.auth_sessions, MemoryAuthSessionStore) + assert isinstance(state.replay_cache, MemoryReplayCache) + assert isinstance(state.pending_claims, MemoryPendingClaimStore) + assert state.redis_client is None + finally: + await state.close() + + +@pytest.mark.asyncio +async def test_runtime_state_raises_when_redis_package_missing(monkeypatch) -> None: + monkeypatch.setenv("AUTHSOME_REDIS_URL", "redis://localhost:6379/0") + get_server_config.cache_clear() + _patch_import(monkeypatch, "redis", None) + + with pytest.raises(RuntimeError, match="Redis state requires installing authsome\\[redis\\]"): + await create_runtime_state() + + +@pytest.mark.asyncio +async def test_runtime_state_uses_redis_stores_and_pings_client(monkeypatch) -> None: + monkeypatch.setenv("AUTHSOME_REDIS_URL", "redis://localhost:6379/0") + get_server_config.cache_clear() + redis_asyncio = types.ModuleType("redis.asyncio") + redis_asyncio.Redis = FakeRedisClient + monkeypatch.setitem(sys.modules, "redis.asyncio", redis_asyncio) + monkeypatch.setitem(sys.modules, "redis", types.ModuleType("redis")) + _patch_import(monkeypatch, "redis", sys.modules["redis"]) + sys.modules["redis"].asyncio = redis_asyncio + + state = await create_runtime_state() + try: + assert isinstance(state.auth_sessions, RedisAuthSessionStore) + assert isinstance(state.replay_cache, RedisReplayCache) + assert isinstance(state.pending_claims, RedisPendingClaimStore) + assert state.redis_client is not None + assert state.redis_client.url == "redis://localhost:6379/0" + assert state.redis_client.decode_responses is True + assert state.redis_client.ping_called is True + finally: + await state.close() + + assert state.redis_client.aclose_called is True + + +@pytest.mark.asyncio +async def test_create_vault_uses_disk_store_without_redis(monkeypatch, tmp_path: Path) -> None: + from authsome.server import dependencies + + class FakeDiskStore: + def __init__(self, directory: str) -> None: + self.directory = directory + + class FakeDekManager: + async def load_or_create(self, secret, raw_kv): + return object() + + monkeypatch.delenv("AUTHSOME_REDIS_URL", raising=False) + monkeypatch.setattr(dependencies, "DiskStore", FakeDiskStore) + monkeypatch.setattr(dependencies, "DekManager", FakeDekManager) + monkeypatch.setattr(dependencies, "load_master_secret", lambda home: "secret") + monkeypatch.setattr(dependencies, "AesGcmEncryptionWrapper", lambda raw_kv, dek: raw_kv) + monkeypatch.setattr(dependencies, "Vault", lambda encrypted_kv: encrypted_kv) + + vault = await create_vault(tmp_path) + + assert isinstance(vault, FakeDiskStore) + assert vault.directory == str(tmp_path / "server" / "kv_store") + + +@pytest.mark.asyncio +async def test_create_vault_uses_redis_store_when_redis_configured(monkeypatch, tmp_path: Path) -> None: + from authsome.server import dependencies + + class FakeRedisStore: + def __init__(self, url: str) -> None: + self.url = url + + class FakeDekManager: + async def load_or_create(self, secret, raw_kv): + return object() + + redis_store_module = types.ModuleType("key_value.aio.stores.redis") + redis_store_module.RedisStore = FakeRedisStore + + monkeypatch.setenv("AUTHSOME_REDIS_URL", "redis://localhost:6379/0") + monkeypatch.setattr(dependencies, "DekManager", FakeDekManager) + monkeypatch.setattr(dependencies, "load_master_secret", lambda home: "secret") + monkeypatch.setattr(dependencies, "AesGcmEncryptionWrapper", lambda raw_kv, dek: raw_kv) + monkeypatch.setattr(dependencies, "Vault", lambda encrypted_kv: encrypted_kv) + _patch_import(monkeypatch, "key_value.aio.stores.redis", redis_store_module) + + vault = await create_vault(tmp_path) + + assert isinstance(vault, FakeRedisStore) + assert vault.url == "redis://localhost:6379/0" From 448c8b24174ea4224faf0a713cd358c098c1b32b Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 16:21:01 +0530 Subject: [PATCH 14/26] fix: clean up failed runtime startup --- src/authsome/server/app.py | 87 ++++++++++----- src/authsome/server/dependencies.py | 4 + .../server/test_runtime_backend_selection.py | 103 ++++++++++++++++++ 3 files changed, 167 insertions(+), 27 deletions(-) diff --git a/src/authsome/server/app.py b/src/authsome/server/app.py index 55695721..c7b6dfa2 100644 --- a/src/authsome/server/app.py +++ b/src/authsome/server/app.py @@ -1,6 +1,6 @@ """FastAPI app factory for the Authsome daemon.""" -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, suppress from importlib.resources import files from fastapi import FastAPI, Request, status @@ -36,39 +36,72 @@ from authsome.server.ui_sessions import UiSessionStore +async def _cleanup_startup_resources(store, audit_log, runtime_state) -> None: + with suppress(Exception): + if audit_log is not None: + audit_log.shutdown() + with suppress(Exception): + if store is not None: + await store.close() + with suppress(Exception): + if runtime_state is not None: + await runtime_state.close() + + @asynccontextmanager async def lifespan(app: FastAPI): """Manage daemon lifecycle.""" - app.state.store = await create_store() - app.state.server_config = await load_server_config(app.state.store) - app.state.audit_log = app.state.store.audit_events.configure_exporter() - app.state.vault = await create_vault(app.state.store.home) - app.state.runtime_state = await create_runtime_state() - app.state.auth_sessions = app.state.runtime_state.auth_sessions - app.state.proof_replay_cache = app.state.runtime_state.replay_cache - app.state.ui_sessions = UiSessionStore( - load_ui_session_signing_secret(app.state.store.home), - pending_claims=app.state.runtime_state.pending_claims, - ) - app.state.provider_repository = ProviderRepository(app.state.store.provider_definitions) - app.state.account_auth_service = create_account_auth_service(app.state.store, app.state.ui_sessions) - app.state.server_base_url = get_server_base_url() - init_posthog() - app.state.identity_bootstrap = create_identity_bootstrap_service( - app.state.store.identity_registry, - app.state.ui_sessions, - store=app.state.store, - server_base_url=app.state.server_base_url, - ) - app.state.ownership_resolver = create_ownership_resolver(app.state.store) - app.state.ownership_cache = {} + store = audit_log = runtime_state = None + try: + store = await create_store() + server_config = await load_server_config(store) + audit_log = store.audit_events.configure_exporter() + vault = await create_vault(store.home) + runtime_state = await create_runtime_state() + auth_sessions = runtime_state.auth_sessions + proof_replay_cache = runtime_state.replay_cache + ui_sessions = UiSessionStore( + load_ui_session_signing_secret(store.home), + pending_claims=runtime_state.pending_claims, + ) + provider_repository = ProviderRepository(store.provider_definitions) + account_auth_service = create_account_auth_service(store, ui_sessions) + server_base_url = get_server_base_url() + init_posthog() + identity_bootstrap = create_identity_bootstrap_service( + store.identity_registry, + ui_sessions, + store=store, + server_base_url=server_base_url, + ) + ownership_resolver = create_ownership_resolver(store) + ownership_cache = {} + except Exception: + await _cleanup_startup_resources(store, audit_log, runtime_state) + raise + + app.state.store = store + app.state.server_config = server_config + app.state.audit_log = audit_log + app.state.vault = vault + app.state.runtime_state = runtime_state + app.state.auth_sessions = auth_sessions + app.state.proof_replay_cache = proof_replay_cache + app.state.ui_sessions = ui_sessions + app.state.provider_repository = provider_repository + app.state.account_auth_service = account_auth_service + app.state.server_base_url = server_base_url + app.state.identity_bootstrap = identity_bootstrap + app.state.ownership_resolver = ownership_resolver + app.state.ownership_cache = ownership_cache yield try: shutdown_posthog() - app.state.audit_log.shutdown() - await app.state.store.close() + if audit_log is not None: + audit_log.shutdown() + if store is not None: + await store.close() finally: - runtime_state = getattr(app.state, "runtime_state", None) if runtime_state is not None: await runtime_state.close() diff --git a/src/authsome/server/dependencies.py b/src/authsome/server/dependencies.py index ae28fa26..1169729f 100644 --- a/src/authsome/server/dependencies.py +++ b/src/authsome/server/dependencies.py @@ -56,6 +56,10 @@ async def create_vault(home: Path) -> Vault: RedisStore = cast(Any, redis_store_module).RedisStore raw_kv = RedisStore(url=server_config.redis_url) + try: + await raw_kv.get("__integrity_probe__", collection="__vault_meta__") + except Exception as exc: + raise RuntimeError("Redis vault storage is unavailable") from exc else: raw_kv = DiskStore(directory=str(server_config.kv_store_dir)) secret = load_master_secret(home) diff --git a/tests/server/test_runtime_backend_selection.py b/tests/server/test_runtime_backend_selection.py index e1ba33d7..f4727b03 100644 --- a/tests/server/test_runtime_backend_selection.py +++ b/tests/server/test_runtime_backend_selection.py @@ -4,8 +4,10 @@ from pathlib import Path import pytest +from fastapi import FastAPI from authsome.auth.sessions import MemoryAuthSessionStore +from authsome.server.app import lifespan from authsome.server.auth_sessions import RedisAuthSessionStore from authsome.server.config import get_server_config from authsome.server.dependencies import create_runtime_state, create_vault @@ -32,6 +34,37 @@ async def aclose(self) -> None: self.aclose_called = True +class FakeAuditLog: + def __init__(self) -> None: + self.shutdown_called = False + + def shutdown(self) -> None: + self.shutdown_called = True + + +class FakeStore: + def __init__(self, home: Path, audit_log: FakeAuditLog) -> None: + self.home = home + self.close_called = False + self.provider_definitions = object() + self.identity_registry = object() + self.audit_events = types.SimpleNamespace(configure_exporter=lambda: audit_log) + + async def close(self) -> None: + self.close_called = True + + +class FakeRuntimeState: + def __init__(self) -> None: + self.close_called = False + self.auth_sessions = object() + self.replay_cache = object() + self.pending_claims = object() + + async def close(self) -> None: + self.close_called = True + + def _patch_import(monkeypatch: pytest.MonkeyPatch, module_name: str, module: types.ModuleType | None) -> None: real_import = builtins.__import__ @@ -96,6 +129,47 @@ async def test_runtime_state_uses_redis_stores_and_pings_client(monkeypatch) -> assert state.redis_client.aclose_called is True +@pytest.mark.asyncio +async def test_lifespan_cleans_up_partial_startup_on_failure(monkeypatch, tmp_path: Path) -> None: + from authsome.server import app as app_module + + audit_log = FakeAuditLog() + store = FakeStore(tmp_path, audit_log) + runtime_state = FakeRuntimeState() + + async def create_store(home=None): + return store + + async def load_server_config(_store): + return object() + + async def create_vault(_home): + return object() + + async def create_runtime_state_stub(): + return runtime_state + + def raise_startup_error(*args, **kwargs): + raise RuntimeError("startup boom") + + monkeypatch.setattr(app_module, "create_store", create_store) + monkeypatch.setattr(app_module, "load_server_config", load_server_config) + monkeypatch.setattr(app_module, "create_vault", create_vault) + monkeypatch.setattr(app_module, "create_runtime_state", create_runtime_state_stub) + monkeypatch.setattr(app_module, "create_account_auth_service", raise_startup_error) + monkeypatch.setattr(app_module, "load_ui_session_signing_secret", lambda home: "secret") + monkeypatch.setattr(app_module, "init_posthog", lambda: None) + monkeypatch.setattr(app_module, "shutdown_posthog", lambda: None) + + with pytest.raises(RuntimeError, match="startup boom"): + async with lifespan(FastAPI()): + pass + + assert store.close_called is True + assert audit_log.shutdown_called is True + assert runtime_state.close_called is True + + @pytest.mark.asyncio async def test_create_vault_uses_disk_store_without_redis(monkeypatch, tmp_path: Path) -> None: from authsome.server import dependencies @@ -128,6 +202,10 @@ async def test_create_vault_uses_redis_store_when_redis_configured(monkeypatch, class FakeRedisStore: def __init__(self, url: str) -> None: self.url = url + self.get_calls: list[tuple[str, str | None]] = [] + + async def get(self, key: str, *, collection: str | None = None): + self.get_calls.append((key, collection)) class FakeDekManager: async def load_or_create(self, secret, raw_kv): @@ -147,3 +225,28 @@ async def load_or_create(self, secret, raw_kv): assert isinstance(vault, FakeRedisStore) assert vault.url == "redis://localhost:6379/0" + assert vault.get_calls == [("__integrity_probe__", "__vault_meta__")] + + +@pytest.mark.asyncio +async def test_create_vault_raises_clear_error_when_redis_probe_fails(monkeypatch, tmp_path: Path) -> None: + from authsome.server import dependencies + + class FailingRedisStore: + def __init__(self, url: str) -> None: + self.url = url + + async def get(self, key: str, *, collection: str | None = None): + raise ConnectionError("redis down") + + redis_store_module = types.ModuleType("key_value.aio.stores.redis") + redis_store_module.RedisStore = FailingRedisStore + + monkeypatch.setenv("AUTHSOME_REDIS_URL", "redis://localhost:6379/0") + get_server_config.cache_clear() + monkeypatch.setattr(dependencies, "AesGcmEncryptionWrapper", lambda raw_kv, dek: raw_kv) + monkeypatch.setattr(dependencies, "Vault", lambda encrypted_kv: encrypted_kv) + _patch_import(monkeypatch, "key_value.aio.stores.redis", redis_store_module) + + with pytest.raises(RuntimeError, match="Redis vault storage is unavailable"): + await create_vault(tmp_path) From ea0fd94e5bff9f652899334a3a485293cde07218 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 16:23:12 +0530 Subject: [PATCH 15/26] fix: close redis client on ping failure --- src/authsome/server/dependencies.py | 6 ++++- .../server/test_runtime_backend_selection.py | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/authsome/server/dependencies.py b/src/authsome/server/dependencies.py index 1169729f..c97f1d9b 100644 --- a/src/authsome/server/dependencies.py +++ b/src/authsome/server/dependencies.py @@ -95,7 +95,11 @@ async def create_runtime_state() -> RuntimeState: Redis = cast(Any, redis_module).Redis client = Redis.from_url(config.redis_url, decode_responses=True) - await client.ping() + try: + await client.ping() + except Exception: + await client.aclose() + raise return RuntimeState( auth_sessions=RedisAuthSessionStore(client), replay_cache=RedisReplayCache(client), diff --git a/tests/server/test_runtime_backend_selection.py b/tests/server/test_runtime_backend_selection.py index f4727b03..8003ed04 100644 --- a/tests/server/test_runtime_backend_selection.py +++ b/tests/server/test_runtime_backend_selection.py @@ -16,6 +16,8 @@ class FakeRedisClient: + last_created = None + def __init__(self) -> None: self.ping_called = False self.aclose_called = False @@ -23,6 +25,7 @@ def __init__(self) -> None: @classmethod def from_url(cls, url: str, decode_responses: bool = False): client = cls() + cls.last_created = client client.url = url client.decode_responses = decode_responses return client @@ -34,6 +37,12 @@ async def aclose(self) -> None: self.aclose_called = True +class FailingPingRedisClient(FakeRedisClient): + async def ping(self) -> None: + self.ping_called = True + raise ConnectionError("ping failed") + + class FakeAuditLog: def __init__(self) -> None: self.shutdown_called = False @@ -129,6 +138,24 @@ async def test_runtime_state_uses_redis_stores_and_pings_client(monkeypatch) -> assert state.redis_client.aclose_called is True +@pytest.mark.asyncio +async def test_runtime_state_closes_redis_client_when_ping_fails(monkeypatch) -> None: + monkeypatch.setenv("AUTHSOME_REDIS_URL", "redis://localhost:6379/0") + get_server_config.cache_clear() + redis_asyncio = types.ModuleType("redis.asyncio") + redis_asyncio.Redis = FailingPingRedisClient + monkeypatch.setitem(sys.modules, "redis.asyncio", redis_asyncio) + monkeypatch.setitem(sys.modules, "redis", types.ModuleType("redis")) + _patch_import(monkeypatch, "redis", sys.modules["redis"]) + sys.modules["redis"].asyncio = redis_asyncio + + with pytest.raises(ConnectionError, match="ping failed"): + await create_runtime_state() + + assert FailingPingRedisClient.last_created is not None + assert FailingPingRedisClient.last_created.aclose_called is True + + @pytest.mark.asyncio async def test_lifespan_cleans_up_partial_startup_on_failure(monkeypatch, tmp_path: Path) -> None: from authsome.server import app as app_module From 3b8cfd01bb7ddd2c1e5cd2e2781138463a186b22 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 16:26:52 +0530 Subject: [PATCH 16/26] feat: add root health check --- src/authsome/server/app.py | 5 ++ src/authsome/server/routes/health.py | 81 +++++++++++++++++++++------- tests/server/test_health_routes.py | 17 ++++++ 3 files changed, 84 insertions(+), 19 deletions(-) create mode 100644 tests/server/test_health_routes.py diff --git a/src/authsome/server/app.py b/src/authsome/server/app.py index c7b6dfa2..11c882f6 100644 --- a/src/authsome/server/app.py +++ b/src/authsome/server/app.py @@ -24,6 +24,7 @@ from authsome.server.routes.auth import browser_router as auth_browser_router from authsome.server.routes.auth import router as auth_router from authsome.server.routes.connections import router as connections_router +from authsome.server.routes.health import HealthResponse, build_health_response from authsome.server.routes.health import router as health_router from authsome.server.routes.identities import router as identities_router from authsome.server.routes.principals import router as principals_router @@ -143,6 +144,10 @@ def ui_auth_required_handler(request: Request, exc: UiAuthRequiredError): def claim_page_redirect(token: str) -> RedirectResponse: return RedirectResponse(url=f"/claim?token={token}", status_code=status.HTTP_307_TEMPORARY_REDIRECT) + @app.get("/health", response_model=HealthResponse) + def root_health(request: Request) -> HealthResponse: + return build_health_response(request) + app.include_router(auth_browser_router) app.include_router(health_router, prefix="/api") app.include_router(identities_router, prefix="/api") diff --git a/src/authsome/server/routes/health.py b/src/authsome/server/routes/health.py index 66d84bce..bcbeacbf 100644 --- a/src/authsome/server/routes/health.py +++ b/src/authsome/server/routes/health.py @@ -24,7 +24,7 @@ def _describe_vault_encryption(vault) -> tuple[str, str]: @router.get("/health", response_model=HealthResponse) -def health(request: Request) -> HealthResponse: +def build_health_response(request: Request) -> HealthResponse: effective_source, backend_description = _describe_vault_encryption(request.app.state.vault) return HealthResponse( status="ok", @@ -36,32 +36,48 @@ def health(request: Request) -> HealthResponse: ) -@router.get("/ready", response_model=ReadyResponse) -async def ready( - request: Request, - auth: CredentialService = Depends(get_protected_auth_service), -) -> ReadyResponse: - checks: dict[str, str] = {} - issues: list[str] = [] - warnings: list[str] = [] +@router.get("/health", response_model=HealthResponse) +def health(request: Request) -> HealthResponse: + return build_health_response(request) - checks["spec_version"] = "ok" +async def _check_store( + store, + checks: dict[str, str], + issues: list[str], +) -> None: try: - checks["store"] = "ok" if await request.app.state.store.is_healthy() else "failed" + checks["store"] = "ok" if await store.is_healthy() else "failed" if checks["store"] == "failed": issues.append("store: readiness check failed") except Exception as exc: checks["store"] = "failed" issues.append(f"store: {exc}") - vault = request.app.state.vault - configured_mode = vault.crypto_source - - # 1. Active Identity Check — scoped to the authenticated caller - checks["identity"] = "ok" - # 2. Providers List Check +async def _check_redis_alive( + runtime_state, + checks: dict[str, str], + issues: list[str], +) -> None: + redis_client = getattr(runtime_state, "redis_client", None) + if redis_client is None: + return + try: + await redis_client.ping() + except Exception as exc: + checks["redis"] = "failed" + issues.append(f"redis: {exc}") + else: + checks["redis"] = "ok" + + +async def _check_providers_and_connections( + auth: CredentialService, + checks: dict[str, str], + issues: list[str], + warnings: list[str], +) -> None: try: await auth.list_providers() checks["providers"] = "ok" @@ -69,7 +85,6 @@ async def ready( checks["providers"] = "failed" issues.append(f"providers: {exc}") - # 3. Connected Providers Check try: conn_list = await auth.list_connections() checks["connections"] = "ok" @@ -80,7 +95,8 @@ async def ready( checks["connections"] = "failed" issues.append(f"connections: {exc}") - # 4. Vault Roundtrip & Store Integrity Check + +async def _check_vault(vault, checks: dict[str, str], issues: list[str]) -> None: try: await vault.put("__ready_test__", "ok", collection="vault:__ready__") value = await vault.get("__ready_test__", collection="vault:__ready__") @@ -101,6 +117,33 @@ async def ready( checks["integrity"] = "failed" issues.append(f"vault: {exc}") + +@router.get("/ready", response_model=ReadyResponse) +async def ready( + request: Request, + auth: CredentialService = Depends(get_protected_auth_service), +) -> ReadyResponse: + checks: dict[str, str] = {} + issues: list[str] = [] + warnings: list[str] = [] + + checks["spec_version"] = "ok" + + store = request.app.state.store + runtime_state = request.app.state.runtime_state + vault = request.app.state.vault + + await _check_store(store, checks, issues) + await _check_redis_alive(runtime_state, checks, issues) + configured_mode = vault.crypto_source + + # 1. Active Identity Check — scoped to the authenticated caller + checks["identity"] = "ok" + + # 2. Providers List Check + await _check_providers_and_connections(auth, checks, issues, warnings) + await _check_vault(vault, checks, issues) + status = "ready" if not issues else "not_ready" effective_source, backend_description = _describe_vault_encryption(vault) return ReadyResponse( diff --git a/tests/server/test_health_routes.py b/tests/server/test_health_routes.py new file mode 100644 index 00000000..f430f477 --- /dev/null +++ b/tests/server/test_health_routes.py @@ -0,0 +1,17 @@ +"""Tests for server health routes.""" + +from fastapi import status + +from tests.server.helpers import create_server_test_client + + +def test_root_health_alias_matches_api_health(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + + with create_server_test_client() as client: + root = client.get("/health") + api = client.get("/api/health") + + assert root.status_code == status.HTTP_200_OK + assert root.json()["status"] == "ok" + assert root.json()["version"] == api.json()["version"] From 5457d10940613636adec71b322d02a6963f921d0 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 16:28:07 +0530 Subject: [PATCH 17/26] fix: remove duplicate health route --- src/authsome/server/routes/health.py | 1 - tests/server/test_health_routes.py | 9 +++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/authsome/server/routes/health.py b/src/authsome/server/routes/health.py index bcbeacbf..6ff90cfc 100644 --- a/src/authsome/server/routes/health.py +++ b/src/authsome/server/routes/health.py @@ -23,7 +23,6 @@ def _describe_vault_encryption(vault) -> tuple[str, str]: return "unavailable", f"Unavailable ({exc})" -@router.get("/health", response_model=HealthResponse) def build_health_response(request: Request) -> HealthResponse: effective_source, backend_description = _describe_vault_encryption(request.app.state.vault) return HealthResponse( diff --git a/tests/server/test_health_routes.py b/tests/server/test_health_routes.py index f430f477..9ad0042d 100644 --- a/tests/server/test_health_routes.py +++ b/tests/server/test_health_routes.py @@ -15,3 +15,12 @@ def test_root_health_alias_matches_api_health(monkeypatch, tmp_path) -> None: assert root.status_code == status.HTTP_200_OK assert root.json()["status"] == "ok" assert root.json()["version"] == api.json()["version"] + + +def test_api_health_route_is_registered_once(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + + with create_server_test_client() as client: + api_health_routes = [route for route in client.app.router.routes if getattr(route, "path", "") == "/api/health"] + + assert len(api_health_routes) == 1 From caac894c1eefabbbe9d59bfeb9dfc7c01346daae Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 16:31:22 +0530 Subject: [PATCH 18/26] test: add gated redis runtime tests --- tests/integration/test_redis_runtime.py | 80 +++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/integration/test_redis_runtime.py diff --git a/tests/integration/test_redis_runtime.py b/tests/integration/test_redis_runtime.py new file mode 100644 index 00000000..98fb7914 --- /dev/null +++ b/tests/integration/test_redis_runtime.py @@ -0,0 +1,80 @@ +import os +import time +import uuid + +import pytest + +from authsome.auth.models.enums import FlowType +from authsome.identity.proof import ProofValidationError +from authsome.server.auth_sessions import RedisAuthSessionStore +from authsome.server.replay_cache import RedisReplayCache +from authsome.server.ui_sessions import RedisPendingClaimStore + +pytestmark = pytest.mark.asyncio + + +def _redis_url() -> str: + value = os.environ.get("AUTHSOME_TEST_REDIS_URL") + if not value: + pytest.skip("AUTHSOME_TEST_REDIS_URL is not set") + return value + + +async def _client(): + pytest.importorskip("redis.asyncio") + from redis.asyncio import Redis + + client = Redis.from_url(_redis_url(), decode_responses=True) + await client.ping() + return client + + +@pytest.mark.asyncio +async def test_redis_replay_cache_rejects_duplicate() -> None: + client = await _client() + prefix = f"test:authsome:{uuid.uuid4().hex}:jti" + cache = RedisReplayCache(client, key_prefix=prefix) + try: + await cache.check_and_store("jti-1", int(time.time()) + 60) + with pytest.raises(ProofValidationError, match="already used"): + await cache.check_and_store("jti-1", int(time.time()) + 60) + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_redis_auth_session_store_survives_new_store_instance() -> None: + client = await _client() + prefix = f"test:authsome:{uuid.uuid4().hex}:session" + first = RedisAuthSessionStore(client, key_prefix=prefix) + second = RedisAuthSessionStore(client, key_prefix=prefix) + try: + session = await first.create( + provider="github", + identity="agent-1", + principal_id="principal_1", + connection_name="default", + flow_type=FlowType.PKCE.value, + ) + session.payload["internal_state"] = "state-1" + await first.index_oauth_state(session) + + assert (await second.get(session.session_id)).identity == "agent-1" + assert (await second.get_by_oauth_state("state-1")).session_id == session.session_id + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_redis_pending_claim_store_consumes_once() -> None: + client = await _client() + prefix = f"test:authsome:{uuid.uuid4().hex}:claim" + store = RedisPendingClaimStore(client, key_prefix=prefix) + try: + pending = await store.create(identity="agent-1") + + assert (await store.consume(pending.token)).identity == "agent-1" + with pytest.raises(KeyError): + await store.consume(pending.token) + finally: + await client.aclose() From ea4eaa791bd3a47214304d6d7b3e2406eae930e9 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 16:37:57 +0530 Subject: [PATCH 19/26] docs: add production self-hosting path --- Dockerfile | 9 +- docker-compose.yml | 59 ++++++++++--- docs/guides/self-hosting.md | 165 ++++++++++++++++-------------------- 3 files changed, 130 insertions(+), 103 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1cd00102..ab1a9676 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,13 +23,18 @@ RUN groupadd -r authsome && \ useradd -r -g authsome -d /home/authsome -m -s /sbin/nologin authsome COPY --from=py-builder /dist /dist -RUN pip install --no-cache-dir /dist/*.whl && rm -rf /dist +COPY --from=ghcr.io/astral-sh/uv:python3.13-bookworm-slim /usr/local/bin/uv /usr/local/bin/uv +RUN wheel="$(find /dist -maxdepth 1 -name '*.whl' -print -quit)" && \ + test -n "$wheel" && \ + uv pip install --system --no-cache "${wheel}[postgres,redis]" && \ + rm -rf /dist ENV AUTHSOME_HOME=/data/authsome EXPOSE 7998 -VOLUME ["/data/authsome"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7998/health', timeout=3).read()"] USER authsome diff --git a/docker-compose.yml b/docker-compose.yml index 08fe4cf1..605b0f7e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,28 +1,65 @@ services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: authsome + POSTGRES_USER: authsome + POSTGRES_PASSWORD: authsome + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U authsome -d authsome"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + redis: + image: redis:7-alpine + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + authsome: build: . image: authsome:latest restart: unless-stopped ports: - "7998:7998" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy volumes: - authsome-data:/data/authsome environment: + AUTHSOME_HOST: 0.0.0.0 + AUTHSOME_PORT: "7998" AUTHSOME_HOME: /data/authsome - # Set this to the public URL of this server so OAuth callbacks resolve correctly. - # Example: AUTHSOME_SERVER_BASE_URL: https://auth.example.com - AUTHSOME_SERVER_BASE_URL: "" - # Encryption mode: "local_key" (default) or "keyring" - AUTHSOME_ENCRYPTION_MODE: local_key - AUTHSOME_LOG_LEVEL: info + AUTHSOME_BASE_URL: ${AUTHSOME_BASE_URL:-http://localhost:7998} + AUTHSOME_DATABASE_URL: postgresql://authsome:authsome@postgres:5432/authsome + DATABASE_URL: postgresql://authsome:authsome@postgres:5432/authsome + AUTHSOME_REDIS_URL: redis://redis:6379/0 + AUTHSOME_DO_NOT_TRACK: "1" + # Set one of the master-key variables from your platform secret store before production use. + AUTHSOME_MASTER_KEY: ${AUTHSOME_MASTER_KEY:-} + AUTHSOME_MASTER_KEY_FILE: ${AUTHSOME_MASTER_KEY_FILE:-} # Uncomment to use a pre-built image from a registry instead of building locally: # image: ghcr.io/agentrhq/authsome:latest - # ── Optional: Caddy reverse proxy for TLS ───────────────────────── - # Uncomment the block below and add a caddy service to terminate TLS. - # labels: - # caddy: auth.example.com - # caddy.reverse_proxy: "{{upstreams 7998}}" + # The authsome home volume is retained for logs and fallback key material. + # Postgres and Redis carry the primary production state. volumes: authsome-data: + postgres-data: + redis-data: diff --git a/docs/guides/self-hosting.md b/docs/guides/self-hosting.md index f174b850..b32f3c04 100644 --- a/docs/guides/self-hosting.md +++ b/docs/guides/self-hosting.md @@ -1,125 +1,110 @@ # Self-hosting Authsome -Run a persistent Authsome daemon in a container — useful for CI runners, shared agent hosts, or any environment where installing Python tooling is inconvenient. +Run Authsome as a production service with Postgres for the server registries and Redis for shared runtime state plus the encrypted vault raw KV layer. ## Quick start -```bash -# Clone the repo (or copy docker-compose.yml) -git clone https://github.com/agentrhq/authsome.git -cd authsome +The repository ships a compose file that wires the daemon to Postgres and Redis. Set a stable master key source first, then bring the stack up and verify the root health check. -# Start the daemon +```bash +export AUTHSOME_MASTER_KEY_FILE="$PWD/.secrets/authsome-master.key" +mkdir -p .secrets +# Write a stable base64-encoded 32-byte key here. docker compose up -d - -# Verify it's running curl http://localhost:7998/health ``` -The daemon is now available at `http://localhost:7998`. +The daemon should answer on `http://localhost:7998`. The root `/health` endpoint is the container health target used by the image and by `docker compose`. -Point agents at it by setting: +## What this deployment does -```bash -export AUTHSOME_BASE_URL=http://localhost:7998 -``` +- Postgres stores the relational server registries: identities, principals, vaults, claims, and bindings. +- Redis stores shared runtime state and, when configured, backs the raw KV layer that holds encrypted vault blobs. +- The Authsome container keeps only a small home directory for logs and optional fallback key material. Primary production state lives in Postgres and Redis. +- Browser sessions remain stateless signed cookies for now. Any future stateful browser session store is tracked separately. + +## Prerequisites + +- Docker and Docker Compose v2. +- Postgres 16. +- Redis 7. +- A stable `AUTHSOME_MASTER_KEY` or `AUTHSOME_MASTER_KEY_FILE`. -## Environment variables +Do not commit production master keys. Use your platform secret store, a Docker secret, or a mounted secret file. + +## Required environment variables | Variable | Default | Description | |---|---|---| -| `AUTHSOME_HOME` | `/data/authsome` | Root directory for credentials, keys, and the database | -| `AUTHSOME_HOST` | `0.0.0.0` | Interface the daemon binds to inside the container | -| `AUTHSOME_PORT` | `7998` | TCP port | -| `AUTHSOME_BASE_URL` | _(derived from host:port)_ | Public URL used to build OAuth callback URLs. **Must be set when behind a reverse proxy.** On client machines, set this to point the CLI at a remote daemon. | -| `AUTHSOME_ENCRYPTION_MODE` | `local_key` | `local_key` stores the master key on disk; `keyring` uses the OS keyring (not available in containers) | -| `AUTHSOME_LOG_LEVEL` | `info` | Uvicorn log level (`debug`, `info`, `warning`, `error`) | -| `AUTHSOME_ANALYTICS` | `1` | Set to `0` to disable telemetry | +| `AUTHSOME_DATABASE_URL` | none | Postgres DSN for the daemon-owned registries. The compose file points this at the bundled Postgres service. | +| `AUTHSOME_REDIS_URL` | none | Redis URL for shared runtime state and the encrypted vault raw KV backend. | +| `AUTHSOME_MASTER_KEY` | none | Base64-encoded 32-byte master key. Highest priority when set. | +| `AUTHSOME_MASTER_KEY_FILE` | none | Path to a file containing the master key. Prefer this for mounted secrets. | +| `AUTHSOME_BASE_URL` | `http://localhost:7998` | Public daemon URL used to build OAuth callback URLs. Set this to the reverse-proxy URL in production. | +| `AUTHSOME_HOME` | `/data/authsome` | Home directory for logs, generated fallback secrets, and other daemon-local files. | +| `AUTHSOME_HOST` | `0.0.0.0` | Host interface the daemon binds to inside the container. | +| `AUTHSOME_PORT` | `7998` | TCP port the daemon listens on. | +| `AUTHSOME_DO_NOT_TRACK` | `1` | Set to `0` only if you intentionally want telemetry enabled. | +| `AUTHSOME_POSTHOG_API_KEY` | none | Enables PostHog analytics when present and telemetry is not opted out. | +| `AUTHSOME_POSTHOG_HOST` | `https://us.i.posthog.com` | Override the PostHog ingestion host if needed. | -## Volume +The current daemon settings still read the legacy `DATABASE_URL` alias internally. The compose file sets `AUTHSOME_DATABASE_URL` and mirrors it into `DATABASE_URL` so the deployment contract stays explicit while the current runtime keeps working. -All credentials and keys live at `AUTHSOME_HOME` (`/data/authsome` by default), which is declared as a Docker named volume. +## Master key resolution -``` -/data/authsome/ - server/ - authsome.db # SQLite database (identities, principals, vaults) - master.key # Vault encryption key — back this up - kv_store/ # Encrypted credential blobs - client/ - logs/ -``` +On startup, Authsome resolves the master key in this order: -> **Keep `master.key` safe.** Without it, stored credentials cannot be decrypted. +1. `AUTHSOME_MASTER_KEY` +2. `AUTHSOME_MASTER_KEY_FILE`, or the default server key file at `AUTHSOME_HOME/server/master.key` +3. The OS keyring entry +4. A generated fallback, stored in the keyring when possible, otherwise written to the default server key file -## Upgrading +`AUTHSOME_MASTER_KEY` is the strongest and cleanest production option because it avoids writing secret material to disk. If you use `AUTHSOME_MASTER_KEY_FILE`, mount it read-only and keep it outside the repository. -```bash -docker compose pull # fetch the latest image -docker compose up -d # restart with zero downtime (data volume is preserved) -``` +## Compose layout + +`docker-compose.yml` runs three services: + +- `authsome` exposes port `7998`, mounts `authsome-data` for logs and fallback secret material, and points the daemon at the internal Postgres and Redis services. +- `postgres` uses `postgres:16-alpine` with a health check and the `postgres-data` volume. +- `redis` uses `redis:7-alpine`, enables append-only persistence, and stores data in the `redis-data` volume. + +The `authsome` service depends on healthy Postgres and Redis before startup. If either backend is missing, unreachable, or the optional Python driver is not installed, the daemon fails fast during boot. + +## Startup and migrations + +Authsome applies relational schema migrations at startup before it serves traffic. That means the daemon must be able to reach Postgres and Redis on boot. In production, treat a failing container start as a dependency or secret problem, not a warning to ignore. + +When Postgres or Redis is configured but unreachable, startup aborts. If the image was built without the matching optional extras, startup also aborts because the required drivers are missing. ## Backup and restore -```bash -# Backup -docker run --rm -v authsome-data:/data/authsome -v $(pwd):/backup \ - busybox tar czf /backup/authsome-backup.tar.gz -C /data/authsome . +Back up these pieces together: -# Restore -docker run --rm -v authsome-data:/data/authsome -v $(pwd):/backup \ - busybox tar xzf /backup/authsome-backup.tar.gz -C /data/authsome -``` +- Postgres data, because it stores the server registries. +- Redis persistence, if you enable or rely on it for encrypted vault blobs or shared runtime state. +- The master key or key file, because encrypted vault data cannot be decrypted without it. +- The `authsome-data` volume only if you want daemon logs or a fallback key file to survive container replacement. -## TLS with Caddy - -Add a Caddy sidecar to the compose file for automatic HTTPS: - -```yaml -services: - authsome: - image: authsome:latest - restart: unless-stopped - expose: - - "7998" - environment: - AUTHSOME_BASE_URL: https://auth.example.com - volumes: - - authsome-data:/data/authsome - - caddy: - image: caddy:2-alpine - restart: unless-stopped - ports: - - "80:80" - - "443:443" - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile:ro - - caddy-data:/data - depends_on: - - authsome - -volumes: - authsome-data: - caddy-data: -``` +Browser sessions remain stateless signed cookies for now, so there is no separate session database to back up yet. -`Caddyfile`: +For restores, bring back the master key first, then restore Postgres and Redis, then start the daemon. -``` -auth.example.com { - reverse_proxy authsome:7998 -} -``` +## Upgrades -## Building the image locally +Pull the new image, restart the stack, and watch the health endpoint until it reports ready: ```bash -docker build -t authsome:local . +docker compose pull +docker compose up -d +curl http://localhost:7998/health ``` -The build is multi-stage: +Because schema migrations run at startup, keep the Postgres and Redis services healthy during the upgrade. If the daemon restarts, re-check `/health` after the migration pass completes. + +## Example production notes -1. **`ui-builder`** — Node 24 + pnpm compiles the Next.js dashboard to static HTML. -2. **`py-builder`** — uv bundles the Python package (including the built UI) into a wheel. -3. **`runtime`** — Slim Python 3.13 image; installs the wheel, runs as a non-root `authsome` user. +- Use your platform secret store for `AUTHSOME_MASTER_KEY` or `AUTHSOME_MASTER_KEY_FILE`. +- Set `AUTHSOME_BASE_URL` to the public URL behind your reverse proxy. +- Keep `AUTHSOME_HOME` mounted only if you want local logs or fallback key material to persist. +- Consider pointing `AUTHSOME_POSTHOG_API_KEY` at a real analytics key only if you have opted in to telemetry. From 5a15e4179b5c14c8c2dab986fcdf461a39b82b0c Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 16:42:14 +0530 Subject: [PATCH 20/26] fix: avoid hardcoded compose secrets --- docker-compose.yml | 22 +++++++++++++--------- docs/guides/self-hosting.md | 11 +++++++---- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 605b0f7e..d62a4b5b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,13 +3,17 @@ services: image: postgres:16-alpine restart: unless-stopped environment: - POSTGRES_DB: authsome - POSTGRES_USER: authsome - POSTGRES_PASSWORD: authsome + POSTGRES_DB: ${AUTHSOME_POSTGRES_DB:-authsome} + POSTGRES_USER: ${AUTHSOME_POSTGRES_USER:-authsome} + POSTGRES_PASSWORD: ${AUTHSOME_POSTGRES_PASSWORD:?set AUTHSOME_POSTGRES_PASSWORD} volumes: - postgres-data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U authsome -d authsome"] + test: + [ + "CMD-SHELL", + "pg_isready -U ${AUTHSOME_POSTGRES_USER:-authsome} -d ${AUTHSOME_POSTGRES_DB:-authsome}", + ] interval: 10s timeout: 5s retries: 5 @@ -46,13 +50,13 @@ services: AUTHSOME_PORT: "7998" AUTHSOME_HOME: /data/authsome AUTHSOME_BASE_URL: ${AUTHSOME_BASE_URL:-http://localhost:7998} - AUTHSOME_DATABASE_URL: postgresql://authsome:authsome@postgres:5432/authsome - DATABASE_URL: postgresql://authsome:authsome@postgres:5432/authsome + AUTHSOME_DATABASE_URL: postgresql://${AUTHSOME_POSTGRES_USER:-authsome}:${AUTHSOME_POSTGRES_PASSWORD:?set AUTHSOME_POSTGRES_PASSWORD}@postgres:5432/${AUTHSOME_POSTGRES_DB:-authsome} + DATABASE_URL: postgresql://${AUTHSOME_POSTGRES_USER:-authsome}:${AUTHSOME_POSTGRES_PASSWORD:?set AUTHSOME_POSTGRES_PASSWORD}@postgres:5432/${AUTHSOME_POSTGRES_DB:-authsome} AUTHSOME_REDIS_URL: redis://redis:6379/0 AUTHSOME_DO_NOT_TRACK: "1" - # Set one of the master-key variables from your platform secret store before production use. - AUTHSOME_MASTER_KEY: ${AUTHSOME_MASTER_KEY:-} - AUTHSOME_MASTER_KEY_FILE: ${AUTHSOME_MASTER_KEY_FILE:-} + # Set AUTHSOME_MASTER_KEY from your platform secret store before production use. + # AUTHSOME_MASTER_KEY_FILE remains available for platforms that mount a secret file into the container. + AUTHSOME_MASTER_KEY: ${AUTHSOME_MASTER_KEY:?set AUTHSOME_MASTER_KEY} # Uncomment to use a pre-built image from a registry instead of building locally: # image: ghcr.io/agentrhq/authsome:latest diff --git a/docs/guides/self-hosting.md b/docs/guides/self-hosting.md index b32f3c04..eb4a4787 100644 --- a/docs/guides/self-hosting.md +++ b/docs/guides/self-hosting.md @@ -7,14 +7,14 @@ Run Authsome as a production service with Postgres for the server registries and The repository ships a compose file that wires the daemon to Postgres and Redis. Set a stable master key source first, then bring the stack up and verify the root health check. ```bash -export AUTHSOME_MASTER_KEY_FILE="$PWD/.secrets/authsome-master.key" -mkdir -p .secrets -# Write a stable base64-encoded 32-byte key here. +export AUTHSOME_POSTGRES_PASSWORD='change-me-to-a-long-random-password' +export AUTHSOME_MASTER_KEY='base64-encoded-32-byte-key' docker compose up -d curl http://localhost:7998/health ``` The daemon should answer on `http://localhost:7998`. The root `/health` endpoint is the container health target used by the image and by `docker compose`. +If your platform mounts a secret file into the container, set `AUTHSOME_MASTER_KEY_FILE` instead of `AUTHSOME_MASTER_KEY` and point it at that mounted path. ## What this deployment does @@ -38,8 +38,11 @@ Do not commit production master keys. Use your platform secret store, a Docker s |---|---|---| | `AUTHSOME_DATABASE_URL` | none | Postgres DSN for the daemon-owned registries. The compose file points this at the bundled Postgres service. | | `AUTHSOME_REDIS_URL` | none | Redis URL for shared runtime state and the encrypted vault raw KV backend. | +| `AUTHSOME_POSTGRES_PASSWORD` | none | Required password used by the bundled Postgres service and the daemon's database URL. | +| `AUTHSOME_POSTGRES_USER` | `authsome` | Postgres role name used by the bundled compose file. | +| `AUTHSOME_POSTGRES_DB` | `authsome` | Postgres database name used by the bundled compose file. | | `AUTHSOME_MASTER_KEY` | none | Base64-encoded 32-byte master key. Highest priority when set. | -| `AUTHSOME_MASTER_KEY_FILE` | none | Path to a file containing the master key. Prefer this for mounted secrets. | +| `AUTHSOME_MASTER_KEY_FILE` | none | Alternative to `AUTHSOME_MASTER_KEY` when your platform mounts the secret into the container as a file. | | `AUTHSOME_BASE_URL` | `http://localhost:7998` | Public daemon URL used to build OAuth callback URLs. Set this to the reverse-proxy URL in production. | | `AUTHSOME_HOME` | `/data/authsome` | Home directory for logs, generated fallback secrets, and other daemon-local files. | | `AUTHSOME_HOST` | `0.0.0.0` | Host interface the daemon binds to inside the container. | From ee25d5ce4922c0c74efa2d314e6c402fe2e2ee54 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 16:44:40 +0530 Subject: [PATCH 21/26] fix: clarify compose master key secret --- docs/guides/self-hosting.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/guides/self-hosting.md b/docs/guides/self-hosting.md index eb4a4787..f9822811 100644 --- a/docs/guides/self-hosting.md +++ b/docs/guides/self-hosting.md @@ -14,7 +14,7 @@ curl http://localhost:7998/health ``` The daemon should answer on `http://localhost:7998`. The root `/health` endpoint is the container health target used by the image and by `docker compose`. -If your platform mounts a secret file into the container, set `AUTHSOME_MASTER_KEY_FILE` instead of `AUTHSOME_MASTER_KEY` and point it at that mounted path. +The included compose file reads `AUTHSOME_MASTER_KEY` from the host environment. `AUTHSOME_MASTER_KEY_FILE` is supported by Authsome itself, but if you want to use a file-mounted secret you must add that mount and pass the file path yourself in a custom compose file. ## What this deployment does @@ -28,9 +28,9 @@ If your platform mounts a secret file into the container, set `AUTHSOME_MASTER_K - Docker and Docker Compose v2. - Postgres 16. - Redis 7. -- A stable `AUTHSOME_MASTER_KEY` or `AUTHSOME_MASTER_KEY_FILE`. +- A stable `AUTHSOME_MASTER_KEY` for the included compose file. -Do not commit production master keys. Use your platform secret store, a Docker secret, or a mounted secret file. +Do not commit production master keys. Use your platform secret store or a Docker secret for the included compose file. If you prefer `AUTHSOME_MASTER_KEY_FILE`, wire up your own secret mount and file path in a custom compose file. ## Required environment variables @@ -42,7 +42,7 @@ Do not commit production master keys. Use your platform secret store, a Docker s | `AUTHSOME_POSTGRES_USER` | `authsome` | Postgres role name used by the bundled compose file. | | `AUTHSOME_POSTGRES_DB` | `authsome` | Postgres database name used by the bundled compose file. | | `AUTHSOME_MASTER_KEY` | none | Base64-encoded 32-byte master key. Highest priority when set. | -| `AUTHSOME_MASTER_KEY_FILE` | none | Alternative to `AUTHSOME_MASTER_KEY` when your platform mounts the secret into the container as a file. | +| `AUTHSOME_MASTER_KEY_FILE` | none | Advanced alternative for custom compose or platform-secret setups where you mount a file into the container and point Authsome at that path yourself. | | `AUTHSOME_BASE_URL` | `http://localhost:7998` | Public daemon URL used to build OAuth callback URLs. Set this to the reverse-proxy URL in production. | | `AUTHSOME_HOME` | `/data/authsome` | Home directory for logs, generated fallback secrets, and other daemon-local files. | | `AUTHSOME_HOST` | `0.0.0.0` | Host interface the daemon binds to inside the container. | @@ -52,6 +52,7 @@ Do not commit production master keys. Use your platform secret store, a Docker s | `AUTHSOME_POSTHOG_HOST` | `https://us.i.posthog.com` | Override the PostHog ingestion host if needed. | The current daemon settings still read the legacy `DATABASE_URL` alias internally. The compose file sets `AUTHSOME_DATABASE_URL` and mirrors it into `DATABASE_URL` so the deployment contract stays explicit while the current runtime keeps working. +The included compose file hard-requires `AUTHSOME_MASTER_KEY` from the host environment; it does not mount a secret file or pass a `_FILE` path for you. ## Master key resolution @@ -62,7 +63,7 @@ On startup, Authsome resolves the master key in this order: 3. The OS keyring entry 4. A generated fallback, stored in the keyring when possible, otherwise written to the default server key file -`AUTHSOME_MASTER_KEY` is the strongest and cleanest production option because it avoids writing secret material to disk. If you use `AUTHSOME_MASTER_KEY_FILE`, mount it read-only and keep it outside the repository. +`AUTHSOME_MASTER_KEY` is the strongest and cleanest production option for the included compose file because it avoids writing secret material to disk. If you use `AUTHSOME_MASTER_KEY_FILE`, mount it read-only, point Authsome at the mounted path, and treat that as a custom compose setup rather than the out-of-the-box quick start. ## Compose layout @@ -107,7 +108,7 @@ Because schema migrations run at startup, keep the Postgres and Redis services h ## Example production notes -- Use your platform secret store for `AUTHSOME_MASTER_KEY` or `AUTHSOME_MASTER_KEY_FILE`. +- Use your platform secret store for `AUTHSOME_MASTER_KEY`. Only switch to `AUTHSOME_MASTER_KEY_FILE` if you have added a real secret mount and file path to your own compose file. - Set `AUTHSOME_BASE_URL` to the public URL behind your reverse proxy. - Keep `AUTHSOME_HOME` mounted only if you want local logs or fallback key material to persist. - Consider pointing `AUTHSOME_POSTHOG_API_KEY` at a real analytics key only if you have opted in to telemetry. From 69b263b071432bd0f139154d30c2680d5b55a429 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 16:51:25 +0530 Subject: [PATCH 22/26] fix: exclude local build artifacts from package --- .dockerignore | 1 + .gitignore | 6 ++++++ pyproject.toml | 8 ++++++++ 3 files changed, 15 insertions(+) diff --git a/.dockerignore b/.dockerignore index b5891033..40b6e19b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,6 +8,7 @@ __pycache__ *.egg-info dist/ .venv/ +.venv*/ .uv/ # Node build artefacts diff --git a/.gitignore b/.gitignore index ad5be38d..c2c1c034 100644 --- a/.gitignore +++ b/.gitignore @@ -153,6 +153,7 @@ activemq-data/ .env .envrc .venv +.venv*/ env/ venv/ ENV/ @@ -209,6 +210,11 @@ cython_debug/ # Generated static dashboard bundle — run scripts/build-ui.sh before uv build src/authsome/ui/web/* +# Local UI dependency installs and builds +ui/node_modules/ +ui/.next/ +ui/out/ + # PyPI configuration file .pypirc diff --git a/pyproject.toml b/pyproject.toml index 8355e34d..4e64f829 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,14 @@ authsome = "authsome.cli.main:cli" packages = ["src/authsome"] artifacts = ["src/authsome/ui/web/**"] +[tool.hatch.build] +exclude = [ + "/.venv*", + "/ui/node_modules", + "/ui/.next", + "/ui/out", +] + [tool.hatch.build.targets.sdist.force-include] "src/authsome/ui/web" = "src/authsome/ui/web" From b374fb49a01a8706842f76c905f48451ed71e5e8 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 16:55:14 +0530 Subject: [PATCH 23/26] fix: address production readiness review --- docker-compose.yml | 3 +- docs/guides/self-hosting.md | 24 +++--- src/authsome/server/config.py | 5 +- src/authsome/server/routes/health.py | 12 ++- src/authsome/server/store/database.py | 10 +++ src/authsome/vault/__init__.py | 22 ++++- .../server/store/test_database_migrations.py | 17 ++++ tests/server/test_config.py | 19 ++++ tests/vault/test_vault.py | 86 +++++++++++++++++++ 9 files changed, 179 insertions(+), 19 deletions(-) create mode 100644 tests/vault/test_vault.py diff --git a/docker-compose.yml b/docker-compose.yml index d62a4b5b..540f7845 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,12 +51,13 @@ services: AUTHSOME_HOME: /data/authsome AUTHSOME_BASE_URL: ${AUTHSOME_BASE_URL:-http://localhost:7998} AUTHSOME_DATABASE_URL: postgresql://${AUTHSOME_POSTGRES_USER:-authsome}:${AUTHSOME_POSTGRES_PASSWORD:?set AUTHSOME_POSTGRES_PASSWORD}@postgres:5432/${AUTHSOME_POSTGRES_DB:-authsome} - DATABASE_URL: postgresql://${AUTHSOME_POSTGRES_USER:-authsome}:${AUTHSOME_POSTGRES_PASSWORD:?set AUTHSOME_POSTGRES_PASSWORD}@postgres:5432/${AUTHSOME_POSTGRES_DB:-authsome} AUTHSOME_REDIS_URL: redis://redis:6379/0 AUTHSOME_DO_NOT_TRACK: "1" # Set AUTHSOME_MASTER_KEY from your platform secret store before production use. # AUTHSOME_MASTER_KEY_FILE remains available for platforms that mount a secret file into the container. AUTHSOME_MASTER_KEY: ${AUTHSOME_MASTER_KEY:?set AUTHSOME_MASTER_KEY} + # Must be identical on every replica because browser sessions are stateless signed JWTs. + AUTHSOME_UI_SESSION_KEY: ${AUTHSOME_UI_SESSION_KEY:?set AUTHSOME_UI_SESSION_KEY} # Uncomment to use a pre-built image from a registry instead of building locally: # image: ghcr.io/agentrhq/authsome:latest diff --git a/docs/guides/self-hosting.md b/docs/guides/self-hosting.md index f9822811..a4dc2e62 100644 --- a/docs/guides/self-hosting.md +++ b/docs/guides/self-hosting.md @@ -9,28 +9,29 @@ The repository ships a compose file that wires the daemon to Postgres and Redis. ```bash export AUTHSOME_POSTGRES_PASSWORD='change-me-to-a-long-random-password' export AUTHSOME_MASTER_KEY='base64-encoded-32-byte-key' +export AUTHSOME_UI_SESSION_KEY='base64-encoded-32-byte-key' docker compose up -d curl http://localhost:7998/health ``` The daemon should answer on `http://localhost:7998`. The root `/health` endpoint is the container health target used by the image and by `docker compose`. -The included compose file reads `AUTHSOME_MASTER_KEY` from the host environment. `AUTHSOME_MASTER_KEY_FILE` is supported by Authsome itself, but if you want to use a file-mounted secret you must add that mount and pass the file path yourself in a custom compose file. +The included compose file reads `AUTHSOME_MASTER_KEY` and `AUTHSOME_UI_SESSION_KEY` from the host environment. The `_FILE` variants are supported by Authsome itself, but if you want to use file-mounted secrets you must add those mounts and pass the file paths yourself in a custom compose file. ## What this deployment does - Postgres stores the relational server registries: identities, principals, vaults, claims, and bindings. - Redis stores shared runtime state and, when configured, backs the raw KV layer that holds encrypted vault blobs. - The Authsome container keeps only a small home directory for logs and optional fallback key material. Primary production state lives in Postgres and Redis. -- Browser sessions remain stateless signed cookies for now. Any future stateful browser session store is tracked separately. +- Browser sessions remain stateless signed cookies for now, so every replica must use the same `AUTHSOME_UI_SESSION_KEY`. Any future stateful browser session store is tracked separately. ## Prerequisites - Docker and Docker Compose v2. - Postgres 16. - Redis 7. -- A stable `AUTHSOME_MASTER_KEY` for the included compose file. +- Stable `AUTHSOME_MASTER_KEY` and `AUTHSOME_UI_SESSION_KEY` values for the included compose file. -Do not commit production master keys. Use your platform secret store or a Docker secret for the included compose file. If you prefer `AUTHSOME_MASTER_KEY_FILE`, wire up your own secret mount and file path in a custom compose file. +Do not commit production secrets. Use your platform secret store or Docker secrets for the included compose file. If you prefer `_FILE` variables, wire up your own secret mounts and file paths in a custom compose file. ## Required environment variables @@ -43,6 +44,8 @@ Do not commit production master keys. Use your platform secret store or a Docker | `AUTHSOME_POSTGRES_DB` | `authsome` | Postgres database name used by the bundled compose file. | | `AUTHSOME_MASTER_KEY` | none | Base64-encoded 32-byte master key. Highest priority when set. | | `AUTHSOME_MASTER_KEY_FILE` | none | Advanced alternative for custom compose or platform-secret setups where you mount a file into the container and point Authsome at that path yourself. | +| `AUTHSOME_UI_SESSION_KEY` | none | Shared signing secret for stateless browser session JWTs. Must be identical on every replica. | +| `AUTHSOME_UI_SESSION_KEY_FILE` | none | Advanced alternative for custom compose or platform-secret setups where you mount the UI session key into the container. | | `AUTHSOME_BASE_URL` | `http://localhost:7998` | Public daemon URL used to build OAuth callback URLs. Set this to the reverse-proxy URL in production. | | `AUTHSOME_HOME` | `/data/authsome` | Home directory for logs, generated fallback secrets, and other daemon-local files. | | `AUTHSOME_HOST` | `0.0.0.0` | Host interface the daemon binds to inside the container. | @@ -51,19 +54,19 @@ Do not commit production master keys. Use your platform secret store or a Docker | `AUTHSOME_POSTHOG_API_KEY` | none | Enables PostHog analytics when present and telemetry is not opted out. | | `AUTHSOME_POSTHOG_HOST` | `https://us.i.posthog.com` | Override the PostHog ingestion host if needed. | -The current daemon settings still read the legacy `DATABASE_URL` alias internally. The compose file sets `AUTHSOME_DATABASE_URL` and mirrors it into `DATABASE_URL` so the deployment contract stays explicit while the current runtime keeps working. -The included compose file hard-requires `AUTHSOME_MASTER_KEY` from the host environment; it does not mount a secret file or pass a `_FILE` path for you. +The daemon still accepts the legacy `DATABASE_URL` alias, but production deployments should set `AUTHSOME_DATABASE_URL`. +The included compose file hard-requires `AUTHSOME_MASTER_KEY` and `AUTHSOME_UI_SESSION_KEY` from the host environment; it does not mount secret files or pass `_FILE` paths for you. -## Master key resolution +## Secret resolution -On startup, Authsome resolves the master key in this order: +On startup, Authsome resolves the master key and UI session signing key in this order: 1. `AUTHSOME_MASTER_KEY` 2. `AUTHSOME_MASTER_KEY_FILE`, or the default server key file at `AUTHSOME_HOME/server/master.key` 3. The OS keyring entry 4. A generated fallback, stored in the keyring when possible, otherwise written to the default server key file -`AUTHSOME_MASTER_KEY` is the strongest and cleanest production option for the included compose file because it avoids writing secret material to disk. If you use `AUTHSOME_MASTER_KEY_FILE`, mount it read-only, point Authsome at the mounted path, and treat that as a custom compose setup rather than the out-of-the-box quick start. +`AUTHSOME_MASTER_KEY` and `AUTHSOME_UI_SESSION_KEY` are the strongest and cleanest production options for the included compose file because they avoid writing secret material to disk. If you use `_FILE` variables, mount them read-only, point Authsome at the mounted paths, and treat that as a custom compose setup rather than the out-of-the-box quick start. ## Compose layout @@ -88,6 +91,7 @@ Back up these pieces together: - Postgres data, because it stores the server registries. - Redis persistence, if you enable or rely on it for encrypted vault blobs or shared runtime state. - The master key or key file, because encrypted vault data cannot be decrypted without it. +- The UI session key or key file, because stateless browser session JWTs cannot be verified consistently across replicas without it. - The `authsome-data` volume only if you want daemon logs or a fallback key file to survive container replacement. Browser sessions remain stateless signed cookies for now, so there is no separate session database to back up yet. @@ -108,7 +112,7 @@ Because schema migrations run at startup, keep the Postgres and Redis services h ## Example production notes -- Use your platform secret store for `AUTHSOME_MASTER_KEY`. Only switch to `AUTHSOME_MASTER_KEY_FILE` if you have added a real secret mount and file path to your own compose file. +- Use your platform secret store for `AUTHSOME_MASTER_KEY` and `AUTHSOME_UI_SESSION_KEY`. Only switch to `_FILE` variables if you have added real secret mounts and file paths to your own compose file. - Set `AUTHSOME_BASE_URL` to the public URL behind your reverse proxy. - Keep `AUTHSOME_HOME` mounted only if you want local logs or fallback key material to persist. - Consider pointing `AUTHSOME_POSTHOG_API_KEY` at a real analytics key only if you have opted in to telemetry. diff --git a/src/authsome/server/config.py b/src/authsome/server/config.py index 0405f862..996ee763 100644 --- a/src/authsome/server/config.py +++ b/src/authsome/server/config.py @@ -16,7 +16,10 @@ class ServerConfig(AuthsomeConfig): port: int = 7998 # Store - database_url: str | None = Field(default=None, validation_alias="DATABASE_URL") + database_url: str | None = Field( + default=None, + validation_alias=AliasChoices("AUTHSOME_DATABASE_URL", "DATABASE_URL"), + ) redis_url: str | None = None postgres_pool_min_size: int = Field(default=1, ge=1) postgres_pool_max_size: int = Field(default=10, ge=1) diff --git a/src/authsome/server/routes/health.py b/src/authsome/server/routes/health.py index 6ff90cfc..8cf804cb 100644 --- a/src/authsome/server/routes/health.py +++ b/src/authsome/server/routes/health.py @@ -1,5 +1,8 @@ """Health and readiness routes.""" +from contextlib import suppress +from uuid import uuid4 + from fastapi import APIRouter, Depends, Request from authsome import __version__ @@ -96,10 +99,10 @@ async def _check_providers_and_connections( async def _check_vault(vault, checks: dict[str, str], issues: list[str]) -> None: + probe_key = f"__ready_test__:{uuid4()}" try: - await vault.put("__ready_test__", "ok", collection="vault:__ready__") - value = await vault.get("__ready_test__", collection="vault:__ready__") - await vault.delete("__ready_test__", collection="vault:__ready__") + await vault.put(probe_key, "ok", collection="vault:__ready__") + value = await vault.get(probe_key, collection="vault:__ready__") if value != "ok": issues.append("vault: readiness roundtrip failed") checks["vault"] = "failed" @@ -115,6 +118,9 @@ async def _check_vault(vault, checks: dict[str, str], issues: list[str]) -> None checks["vault"] = "failed" checks["integrity"] = "failed" issues.append(f"vault: {exc}") + finally: + with suppress(Exception): + await vault.delete(probe_key, collection="vault:__ready__") @router.get("/ready", response_model=ReadyResponse) diff --git a/src/authsome/server/store/database.py b/src/authsome/server/store/database.py index 7dff31c7..6888fdd7 100644 --- a/src/authsome/server/store/database.py +++ b/src/authsome/server/store/database.py @@ -13,6 +13,7 @@ from authsome.server.config import get_server_config StoreBackend = Literal["sqlite", "postgres"] +_POSTGRES_SCHEMA_LOCK_ID = 715_504_817_119_338_103 @dataclass(frozen=True) @@ -269,6 +270,15 @@ def build_schema(backend: StoreBackend) -> list[str]: async def initialize_schema(database: StoreDatabase) -> None: + if database.backend == "postgres": + async with database.transaction(): + await database.execute("SELECT pg_advisory_xact_lock(?)", [_POSTGRES_SCHEMA_LOCK_ID]) + await _apply_schema_migrations(database) + return + await _apply_schema_migrations(database) + + +async def _apply_schema_migrations(database: StoreDatabase) -> None: await database.execute("CREATE TABLE IF NOT EXISTS store_schema_version (version INTEGER PRIMARY KEY)") applied_rows = await database.fetch_all("SELECT version FROM store_schema_version") applied = {int(row["version"]) for row in applied_rows} diff --git a/src/authsome/vault/__init__.py b/src/authsome/vault/__init__.py index 943c31d0..303255d2 100644 --- a/src/authsome/vault/__init__.py +++ b/src/authsome/vault/__init__.py @@ -3,7 +3,7 @@ import builtins import json -from key_value.aio.protocols.key_value import AsyncKeyValue +from key_value.aio.protocols.key_value import AsyncEnumerateKeysProtocol, AsyncKeyValue class Vault: @@ -39,6 +39,20 @@ async def _get_index(self, collection: str) -> builtins.list[str]: async def _save_index(self, collection: str, keys: builtins.list[str]) -> None: await self._kv.put("__index__", {"data": json.dumps(sorted(keys))}, collection=collection) + def _enumerable_kv(self) -> AsyncEnumerateKeysProtocol | None: + if isinstance(self._kv, AsyncEnumerateKeysProtocol): + return self._kv + wrapped = getattr(self._kv, "key_value", None) + if isinstance(wrapped, AsyncEnumerateKeysProtocol): + return wrapped + return None + + async def _list_indexed_keys(self, collection: str) -> builtins.list[str]: + enumerable_kv = self._enumerable_kv() + if enumerable_kv is not None: + return sorted(key for key in await enumerable_kv.keys(collection=collection) if key != "__index__") + return await self._get_index(collection) + # ── Encrypted KV interface ──────────────────────────────────────────── async def get(self, key: str, *, collection: str) -> str | None: @@ -51,7 +65,7 @@ async def get(self, key: str, *, collection: str) -> str | None: async def put(self, key: str, value: str, *, collection: str) -> None: """Encrypt and store a value.""" await self._kv.put(key, {"data": value}, collection=collection) - if key != "__index__": + if key != "__index__" and self._enumerable_kv() is None: idx = set(await self._get_index(collection)) if key not in idx: idx.add(key) @@ -60,7 +74,7 @@ async def put(self, key: str, value: str, *, collection: str) -> None: async def delete(self, key: str, *, collection: str) -> bool: """Delete a key. Returns True if the key existed.""" existed = await self._kv.delete(key, collection=collection) - if existed and key != "__index__": + if existed and key != "__index__" and self._enumerable_kv() is None: idx = set(await self._get_index(collection)) idx.discard(key) await self._save_index(collection, builtins.list(idx)) @@ -68,7 +82,7 @@ async def delete(self, key: str, *, collection: str) -> bool: async def list(self, prefix: str = "", *, collection: str) -> builtins.list[str]: """List all keys matching a prefix within a collection.""" - idx = await self._get_index(collection) + idx = await self._list_indexed_keys(collection) if prefix: return [k for k in idx if k.startswith(prefix)] return builtins.list(idx) diff --git a/tests/server/store/test_database_migrations.py b/tests/server/store/test_database_migrations.py index 4a658c05..e1efde58 100644 --- a/tests/server/store/test_database_migrations.py +++ b/tests/server/store/test_database_migrations.py @@ -8,6 +8,7 @@ StoreDatabase, StoreDatabaseConfig, build_migrations, + initialize_schema, open_store_database, resolve_store_database_config, ) @@ -150,3 +151,19 @@ async def test_postgres_transaction_uses_single_pooled_connection(tmp_path: Path assert pool.acquire_count == 1 assert connection.execute_calls == [("INSERT INTO audit_events (event_id) VALUES ($1)", ("evt_1",))] + + +@pytest.mark.asyncio +async def test_postgres_migrations_take_advisory_lock(tmp_path: Path) -> None: + config = StoreDatabaseConfig(backend="postgres", dsn="postgresql://localhost:5432/authsome", home=tmp_path) + connection = _FakeConnection() + db = StoreDatabase(config=config, connection=connection) + try: + await initialize_schema(db) + finally: + await db.close() + + assert connection.transaction_enters == 1 + assert connection.transaction_exits == 1 + assert connection.execute_calls[0][0] == "SELECT pg_advisory_xact_lock($1)" + assert isinstance(connection.execute_calls[0][1][0], int) diff --git a/tests/server/test_config.py b/tests/server/test_config.py index 09b052a9..dd28ff4b 100644 --- a/tests/server/test_config.py +++ b/tests/server/test_config.py @@ -13,6 +13,25 @@ def test_server_config_reads_redis_url(monkeypatch) -> None: assert config.redis_url == "redis://localhost:6379/0" +def test_server_config_reads_authsome_database_url(monkeypatch) -> None: + monkeypatch.setenv("AUTHSOME_DATABASE_URL", "postgresql://authsome:secret@localhost/authsome") + monkeypatch.delenv("DATABASE_URL", raising=False) + + config = ServerConfig() + + assert config.database_url == "postgresql://authsome:secret@localhost/authsome" + assert config.database == "postgresql://authsome:secret@localhost/authsome" + + +def test_server_config_keeps_legacy_database_url_alias(monkeypatch) -> None: + monkeypatch.delenv("AUTHSOME_DATABASE_URL", raising=False) + monkeypatch.setenv("DATABASE_URL", "postgresql://legacy:secret@localhost/authsome") + + config = ServerConfig() + + assert config.database_url == "postgresql://legacy:secret@localhost/authsome" + + def test_server_config_exposes_postgres_pool_settings(monkeypatch) -> None: monkeypatch.setenv("AUTHSOME_POSTGRES_POOL_MIN_SIZE", "2") monkeypatch.setenv("AUTHSOME_POSTGRES_POOL_MAX_SIZE", "9") diff --git a/tests/vault/test_vault.py b/tests/vault/test_vault.py new file mode 100644 index 00000000..02a238d0 --- /dev/null +++ b/tests/vault/test_vault.py @@ -0,0 +1,86 @@ +import json +from collections.abc import Mapping, Sequence +from typing import Any, SupportsFloat + +import pytest + +from authsome.vault import Vault + + +class EnumerableKv: + def __init__(self) -> None: + self.data: dict[str, dict[str, dict[str, Any]]] = {} + + async def get(self, key: str, *, collection: str | None = None) -> dict[str, Any] | None: + return self.data.get(collection or "default_collection", {}).get(key) + + async def put( + self, + key: str, + value: Mapping[str, Any], + *, + collection: str | None = None, + ttl: SupportsFloat | None = None, + ) -> None: + _ = ttl + self.data.setdefault(collection or "default_collection", {})[key] = dict(value) + + async def delete(self, key: str, *, collection: str | None = None) -> bool: + values = self.data.setdefault(collection or "default_collection", {}) + existed = key in values + values.pop(key, None) + return existed + + async def get_many(self, keys: Sequence[str], *, collection: str | None = None) -> list[dict[str, Any] | None]: + return [await self.get(key, collection=collection) for key in keys] + + async def put_many( + self, + keys: Sequence[str], + values: Sequence[Mapping[str, Any]], + *, + collection: str | None = None, + ttl: SupportsFloat | None = None, + ) -> None: + for key, value in zip(keys, values, strict=True): + await self.put(key, value, collection=collection, ttl=ttl) + + async def delete_many(self, keys: Sequence[str], *, collection: str | None = None) -> int: + deleted = 0 + for key in keys: + if await self.delete(key, collection=collection): + deleted += 1 + return deleted + + async def ttl(self, key: str, *, collection: str | None = None) -> tuple[dict[str, Any] | None, float | None]: + return await self.get(key, collection=collection), None + + async def ttl_many( + self, + keys: Sequence[str], + *, + collection: str | None = None, + ) -> list[tuple[dict[str, Any] | None, float | None]]: + return [await self.ttl(key, collection=collection) for key in keys] + + async def keys(self, collection: str | None = None, *, limit: int | None = None) -> list[str]: + keys = sorted(self.data.get(collection or "default_collection", {})) + return keys[:limit] if limit is not None else keys + + +@pytest.mark.asyncio +async def test_vault_lists_from_enumerable_backend_instead_of_manual_index() -> None: + kv = EnumerableKv() + vault = Vault(kv) + + await vault.put("beta", "2", collection="vault:vault_1") + await vault.put("alpha", "1", collection="vault:vault_1") + kv.data["vault:vault_1"]["__index__"] = {"data": json.dumps(["stale"])} + + assert await vault.list(collection="vault:vault_1") == ["alpha", "beta"] + assert await vault.list("alp", collection="vault:vault_1") == ["alpha"] + + await vault.delete("alpha", collection="vault:vault_1") + + assert await vault.list(collection="vault:vault_1") == ["beta"] + assert kv.data["vault:vault_1"]["__index__"] == {"data": json.dumps(["stale"])} From ff3d5426a51ae0ab9a74e4cb67f95f92cd1f62b5 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 10 Jun 2026 16:59:43 +0530 Subject: [PATCH 24/26] fix: harden container startup path --- Dockerfile | 6 ++++-- docs/guides/self-hosting.md | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index ab1a9676..e7d12aa9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,9 @@ RUN mkdir -p src/authsome/ui/web && \ FROM python:3.13-slim AS runtime RUN groupadd -r authsome && \ - useradd -r -g authsome -d /home/authsome -m -s /sbin/nologin authsome + useradd -r -g authsome -d /home/authsome -m -s /sbin/nologin authsome && \ + mkdir -p /data/authsome && \ + chown -R authsome:authsome /data/authsome COPY --from=py-builder /dist /dist COPY --from=ghcr.io/astral-sh/uv:python3.13-bookworm-slim /usr/local/bin/uv /usr/local/bin/uv @@ -38,5 +40,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ USER authsome -ENTRYPOINT ["authsome", "daemon", "serve"] +ENTRYPOINT ["authsome", "--log-file", "", "daemon", "serve"] CMD ["--host", "0.0.0.0", "--port", "7998"] diff --git a/docs/guides/self-hosting.md b/docs/guides/self-hosting.md index a4dc2e62..0c8f8654 100644 --- a/docs/guides/self-hosting.md +++ b/docs/guides/self-hosting.md @@ -100,7 +100,7 @@ For restores, bring back the master key first, then restore Postgres and Redis, ## Upgrades -Pull the new image, restart the stack, and watch the health endpoint until it reports ready: +Pull the new image, restart the stack, and watch the health endpoint until it responds: ```bash docker compose pull From be5c646b5ba3762f1627067f8f5e5d0af2ab453e Mon Sep 17 00:00:00 2001 From: beubax Date: Fri, 12 Jun 2026 12:42:06 +0530 Subject: [PATCH 25/26] fix: require production backend URLs --- docker-compose.yml | 1 + docs/guides/self-hosting.md | 3 +- src/authsome/config.py | 2 +- src/authsome/server/config.py | 7 ++++ src/authsome/server/store/database.py | 9 +++-- tests/server/test_config.py | 49 +++++++++++++++++++++++++++ 6 files changed, 66 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 540f7845..8b0a46cf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,7 @@ services: AUTHSOME_HOST: 0.0.0.0 AUTHSOME_PORT: "7998" AUTHSOME_HOME: /data/authsome + AUTHSOME_ENV: prod AUTHSOME_BASE_URL: ${AUTHSOME_BASE_URL:-http://localhost:7998} AUTHSOME_DATABASE_URL: postgresql://${AUTHSOME_POSTGRES_USER:-authsome}:${AUTHSOME_POSTGRES_PASSWORD:?set AUTHSOME_POSTGRES_PASSWORD}@postgres:5432/${AUTHSOME_POSTGRES_DB:-authsome} AUTHSOME_REDIS_URL: redis://redis:6379/0 diff --git a/docs/guides/self-hosting.md b/docs/guides/self-hosting.md index 0c8f8654..6b10e3f5 100644 --- a/docs/guides/self-hosting.md +++ b/docs/guides/self-hosting.md @@ -37,6 +37,7 @@ Do not commit production secrets. Use your platform secret store or Docker secre | Variable | Default | Description | |---|---|---| +| `AUTHSOME_ENV` | `dev` | Runtime mode. Set to `prod` for production deployments; in `prod`, `AUTHSOME_DATABASE_URL` and `AUTHSOME_REDIS_URL` are required. | | `AUTHSOME_DATABASE_URL` | none | Postgres DSN for the daemon-owned registries. The compose file points this at the bundled Postgres service. | | `AUTHSOME_REDIS_URL` | none | Redis URL for shared runtime state and the encrypted vault raw KV backend. | | `AUTHSOME_POSTGRES_PASSWORD` | none | Required password used by the bundled Postgres service and the daemon's database URL. | @@ -54,7 +55,7 @@ Do not commit production secrets. Use your platform secret store or Docker secre | `AUTHSOME_POSTHOG_API_KEY` | none | Enables PostHog analytics when present and telemetry is not opted out. | | `AUTHSOME_POSTHOG_HOST` | `https://us.i.posthog.com` | Override the PostHog ingestion host if needed. | -The daemon still accepts the legacy `DATABASE_URL` alias, but production deployments should set `AUTHSOME_DATABASE_URL`. +The daemon still accepts the legacy `DATABASE_URL` alias, but production deployments should set `AUTHSOME_DATABASE_URL`. The included compose file sets `AUTHSOME_ENV=prod`, which makes the Postgres and Redis URLs mandatory at startup. The included compose file hard-requires `AUTHSOME_MASTER_KEY` and `AUTHSOME_UI_SESSION_KEY` from the host environment; it does not mount secret files or pass `_FILE` paths for you. ## Secret resolution diff --git a/src/authsome/config.py b/src/authsome/config.py index e95930cf..b35f4546 100644 --- a/src/authsome/config.py +++ b/src/authsome/config.py @@ -15,7 +15,7 @@ class AuthsomeConfig(BaseSettings): model_config = SettingsConfigDict(env_prefix="AUTHSOME_") version: str = __version__ - env: Literal["prod", "dev", "test"] = "prod" + env: Literal["prod", "dev", "test"] = "dev" home: Path = Field(default=Path.home() / ".authsome") base_url: str = Field(default="http://127.0.0.1:7998") diff --git a/src/authsome/server/config.py b/src/authsome/server/config.py index f0362b03..be2703c0 100644 --- a/src/authsome/server/config.py +++ b/src/authsome/server/config.py @@ -28,6 +28,13 @@ class ServerConfig(AuthsomeConfig): def validate_postgres_pool_sizes(self) -> "ServerConfig": if self.postgres_pool_min_size > self.postgres_pool_max_size: raise ValueError("postgres_pool_min_size must be less than or equal to postgres_pool_max_size") + if self.env == "prod": + if not self.database_url: + raise ValueError("AUTHSOME_DATABASE_URL is required when AUTHSOME_ENV=prod") + if not self.database_url.startswith(("postgresql://", "postgres://")): + raise ValueError("AUTHSOME_DATABASE_URL must be a Postgres URL when AUTHSOME_ENV=prod") + if not self.redis_url: + raise ValueError("AUTHSOME_REDIS_URL is required when AUTHSOME_ENV=prod") return self # Lifetimes, in seconds diff --git a/src/authsome/server/store/database.py b/src/authsome/server/store/database.py index 64d8978b..891766fe 100644 --- a/src/authsome/server/store/database.py +++ b/src/authsome/server/store/database.py @@ -117,13 +117,16 @@ async def execute(self, sql: str, params: Sequence[Any] = ()) -> None: async def execute_rowcount(self, sql: str, params: Sequence[Any] = ()) -> int: if self.backend == "sqlite": - cursor = await self._connection.execute(sql, params) - await self._connection.commit() + connection = self._connection + assert connection is not None + cursor = await connection.execute(sql, params) + await connection.commit() rowcount = cursor.rowcount await cursor.close() return rowcount - status = await self._connection.execute(self._sql(sql), *params) + async with self._postgres_connection() as connection: + status = await connection.execute(self._sql(sql), *params) _, _, count = status.partition(" ") return int(count) if count else 0 diff --git a/tests/server/test_config.py b/tests/server/test_config.py index dd28ff4b..e6a9111c 100644 --- a/tests/server/test_config.py +++ b/tests/server/test_config.py @@ -13,6 +13,14 @@ def test_server_config_reads_redis_url(monkeypatch) -> None: assert config.redis_url == "redis://localhost:6379/0" +def test_server_config_defaults_to_dev_env(monkeypatch) -> None: + monkeypatch.delenv("AUTHSOME_ENV", raising=False) + + config = ServerConfig() + + assert config.env == "dev" + + def test_server_config_reads_authsome_database_url(monkeypatch) -> None: monkeypatch.setenv("AUTHSOME_DATABASE_URL", "postgresql://authsome:secret@localhost/authsome") monkeypatch.delenv("DATABASE_URL", raising=False) @@ -52,6 +60,47 @@ def test_server_config_defaults_preserve_local_paths(tmp_path: Path) -> None: assert config.kv_store_dir == tmp_path / "server" / "kv_store" +def test_server_config_requires_database_url_in_prod(monkeypatch) -> None: + monkeypatch.setenv("AUTHSOME_ENV", "prod") + monkeypatch.delenv("AUTHSOME_DATABASE_URL", raising=False) + monkeypatch.delenv("DATABASE_URL", raising=False) + monkeypatch.setenv("AUTHSOME_REDIS_URL", "redis://localhost:6379/0") + + with pytest.raises(ValueError, match="AUTHSOME_DATABASE_URL is required when AUTHSOME_ENV=prod"): + ServerConfig() + + +def test_server_config_requires_redis_url_in_prod(monkeypatch) -> None: + monkeypatch.setenv("AUTHSOME_ENV", "prod") + monkeypatch.setenv("AUTHSOME_DATABASE_URL", "postgresql://authsome:secret@localhost/authsome") + monkeypatch.delenv("DATABASE_URL", raising=False) + monkeypatch.delenv("AUTHSOME_REDIS_URL", raising=False) + + with pytest.raises(ValueError, match="AUTHSOME_REDIS_URL is required when AUTHSOME_ENV=prod"): + ServerConfig() + + +def test_server_config_requires_postgres_database_url_in_prod(monkeypatch) -> None: + monkeypatch.setenv("AUTHSOME_ENV", "prod") + monkeypatch.setenv("AUTHSOME_DATABASE_URL", "sqlite:////tmp/authsome.db") + monkeypatch.setenv("AUTHSOME_REDIS_URL", "redis://localhost:6379/0") + + with pytest.raises(ValueError, match="AUTHSOME_DATABASE_URL must be a Postgres URL when AUTHSOME_ENV=prod"): + ServerConfig() + + +def test_server_config_accepts_prod_with_database_and_redis_urls(monkeypatch) -> None: + monkeypatch.setenv("AUTHSOME_ENV", "prod") + monkeypatch.setenv("AUTHSOME_DATABASE_URL", "postgresql://authsome:secret@localhost/authsome") + monkeypatch.setenv("AUTHSOME_REDIS_URL", "redis://localhost:6379/0") + + config = ServerConfig() + + assert config.env == "prod" + assert config.database_url == "postgresql://authsome:secret@localhost/authsome" + assert config.redis_url == "redis://localhost:6379/0" + + def test_server_config_rejects_invalid_postgres_pool_range() -> None: with pytest.raises(ValueError, match="postgres_pool_min_size must be less than or equal to postgres_pool_max_size"): ServerConfig(postgres_pool_min_size=10, postgres_pool_max_size=2) From eb37f84c9fbbc1b940eacaa8baa2afbb09ad59c7 Mon Sep 17 00:00:00 2001 From: beubax Date: Fri, 12 Jun 2026 12:59:49 +0530 Subject: [PATCH 26/26] fix: hide optional asyncpg import from ty --- src/authsome/server/store/database.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/authsome/server/store/database.py b/src/authsome/server/store/database.py index 891766fe..12b9edc8 100644 --- a/src/authsome/server/store/database.py +++ b/src/authsome/server/store/database.py @@ -4,8 +4,9 @@ from contextlib import asynccontextmanager from contextvars import ContextVar from dataclasses import dataclass +from importlib import import_module from pathlib import Path -from typing import Any, Literal +from typing import Any, Literal, cast from urllib.parse import urlparse import aiosqlite @@ -213,12 +214,13 @@ async def open_store_database(config: StoreDatabaseConfig) -> StoreDatabase: return database try: - import asyncpg # noqa: PLC0415 + asyncpg = import_module("asyncpg") except ImportError as exc: raise RuntimeError("Postgres Store requires installing authsome[postgres]") from exc server_config = get_server_config(config.home) - pool = await asyncpg.create_pool( + asyncpg_module = cast(Any, asyncpg) + pool = await asyncpg_module.create_pool( config.dsn, min_size=server_config.postgres_pool_min_size, max_size=server_config.postgres_pool_max_size,