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/Dockerfile b/Dockerfile index 1cd00102..e7d12aa9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,18 +20,25 @@ 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 -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 -ENTRYPOINT ["authsome", "daemon", "serve"] +ENTRYPOINT ["authsome", "--log-file", "", "daemon", "serve"] CMD ["--host", "0.0.0.0", "--port", "7998"] diff --git a/docker-compose.yml b/docker-compose.yml index 08fe4cf1..8b0a46cf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,28 +1,71 @@ services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + 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_POSTGRES_USER:-authsome} -d ${AUTHSOME_POSTGRES_DB:-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_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 + 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 - # ── 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..6b10e3f5 100644 --- a/docs/guides/self-hosting.md +++ b/docs/guides/self-hosting.md @@ -1,125 +1,119 @@ # 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_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 - -# 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`. +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. -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, 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. +- Stable `AUTHSOME_MASTER_KEY` and `AUTHSOME_UI_SESSION_KEY` values for the included compose file. -## Environment variables +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 | 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_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. | +| `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 | 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. | +| `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 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. -All credentials and keys live at `AUTHSOME_HOME` (`/data/authsome` by default), which is declared as a Docker named volume. +## Secret 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 and UI session signing 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` 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. -```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 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. -## 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 responds: ```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` 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/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. diff --git a/pyproject.toml b/pyproject.toml index 585ecca8..4e64f829 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", @@ -66,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" 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/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/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..11c882f6 100644 --- a/src/authsome/server/app.py +++ b/src/authsome/server/app.py @@ -1,20 +1,19 @@ """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 from fastapi.responses import JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles -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, create_identity_bootstrap_service, create_ownership_resolver, + create_runtime_state, create_store, create_vault, get_server_base_url, @@ -25,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 @@ -37,32 +37,74 @@ 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.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.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 - shutdown_posthog() - app.state.audit_log.shutdown() - await app.state.store.close() + try: + shutdown_posthog() + if audit_log is not None: + audit_log.shutdown() + if store is not None: + await store.close() + finally: + if runtime_state is not None: + await runtime_state.close() def create_app() -> FastAPI: @@ -102,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/auth_sessions.py b/src/authsome/server/auth_sessions.py new file mode 100644 index 00000000..a2b68597 --- /dev/null +++ b/src/authsome/server/auth_sessions.py @@ -0,0 +1,98 @@ +"""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}" + + def _session_state_key(self, session_id: str) -> str: + return f"{self._key_prefix}:session-state:{session_id}" + + 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: + 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: + 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: + 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: + 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/config.py b/src/authsome/server/config.py index 04f2d44d..be2703c0 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 @@ -16,7 +16,26 @@ 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) + + @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") + 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 ui_bootstrap_ttl_seconds: int = 300 diff --git a/src/authsome/server/dependencies.py b/src/authsome/server/dependencies.py index 54d9c782..c97f1d9b 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,66 @@ 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) + 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) 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) + try: + await client.ping() + except Exception: + await client.aclose() + raise + 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/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/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 98aa7d2e..e4352e42 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 @@ -144,8 +144,8 @@ 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, ) + 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 @@ -207,7 +207,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/health.py b/src/authsome/server/routes/health.py index 66d84bce..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__ @@ -23,8 +26,7 @@ def _describe_vault_encryption(vault) -> tuple[str, str]: return "unavailable", f"Unavailable ({exc})" -@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 +38,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 +87,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,11 +97,12 @@ 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: + 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" @@ -100,6 +118,36 @@ async def ready( 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) +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) diff --git a/src/authsome/server/routes/ui.py b/src/authsome/server/routes/ui.py index 058dd87c..f19db76a 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.""" @@ -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/store/database.py b/src/authsome/server/store/database.py index a076d57b..12b9edc8 100644 --- a/src/authsome/server/store/database.py +++ b/src/authsome/server/store/database.py @@ -2,17 +2,19 @@ from collections.abc import AsyncIterator, Sequence 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 -import asyncpg 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) @@ -24,12 +26,30 @@ 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 + self._transaction_connection: ContextVar[Any | None] = ContextVar("store_transaction_connection", default=None) @property def backend(self) -> StoreBackend: @@ -48,40 +68,66 @@ def _sql(self, sql: str) -> str: parts.append(char) return "".join(parts) + @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 + 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_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 @@ -92,15 +138,33 @@ 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 + 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 @@ -112,7 +176,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: @@ -145,8 +213,19 @@ async def open_store_database(config: StoreDatabaseConfig) -> StoreDatabase: await initialize_schema(database) return database - connection = await asyncpg.connect(config.dsn) - database = StoreDatabase(config=config, connection=connection) + try: + 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) + 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, + ) + database = StoreDatabase(config=config, pool=pool) await initialize_schema(database) return database @@ -161,9 +240,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" ")", @@ -220,7 +296,24 @@ def build_schema(backend: StoreBackend) -> list[str]: async def initialize_schema(database: StoreDatabase) -> None: - await database.execute_many(build_schema(database.backend)) + 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} + 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/src/authsome/server/ui_sessions.py b/src/authsome/server/ui_sessions.py index 89408da6..3e427171 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,29 +66,29 @@ 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, 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 - 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 +96,103 @@ 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), + ) + 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: + 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: + 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 + + +class UiSessionStore: + """Browser session helper with pluggable pending-claim storage.""" + + 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 +243,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/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/auth/test_session_store_contract.py b/tests/auth/test_session_store_contract.py new file mode 100644 index 00000000..be4df724 --- /dev/null +++ b/tests/auth/test_session_store_contract.py @@ -0,0 +1,47 @@ +import pytest + +from authsome.auth.models.enums import FlowType +from authsome.auth.sessions import AuthSessionStatus, AuthSessionStore, 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 + + +def test_auth_session_store_alias_constructs_memory_store() -> None: + assert isinstance(AuthSessionStore(), MemoryAuthSessionStore) 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/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() diff --git a/tests/server/store/test_database_migrations.py b/tests/server/store/test_database_migrations.py new file mode 100644 index 00000000..e1efde58 --- /dev/null +++ b/tests/server/store/test_database_migrations.py @@ -0,0 +1,169 @@ +import builtins +import sys +from pathlib import Path + +import pytest + +from authsome.server.store.database import ( + StoreDatabase, + StoreDatabaseConfig, + build_migrations, + initialize_schema, + open_store_database, + resolve_store_database_config, +) + + +@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 + + +@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 MAX(version) AS version FROM store_schema_version") + finally: + await database.close() + + assert row == {"version": max(migration.version for migration in 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://") + + +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",))] + + +@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 new file mode 100644 index 00000000..e6a9111c --- /dev/null +++ b/tests/server/test_config.py @@ -0,0 +1,106 @@ +from pathlib import Path + +import pytest + +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_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) + + 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") + 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" + + +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) diff --git a/tests/server/test_health_routes.py b/tests/server/test_health_routes.py new file mode 100644 index 00000000..9ad0042d --- /dev/null +++ b/tests/server/test_health_routes.py @@ -0,0 +1,26 @@ +"""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"] + + +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 diff --git a/tests/server/test_pending_claim_store.py b/tests/server/test_pending_claim_store.py new file mode 100644 index 00000000..26a7ee89 --- /dev/null +++ b/tests/server/test_pending_claim_store.py @@ -0,0 +1,85 @@ +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] = {} + 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 + 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" + 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) 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")) 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 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) diff --git a/tests/server/test_runtime_backend_selection.py b/tests/server/test_runtime_backend_selection.py new file mode 100644 index 00000000..8003ed04 --- /dev/null +++ b/tests/server/test_runtime_backend_selection.py @@ -0,0 +1,279 @@ +import builtins +import sys +import types +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 +from authsome.server.replay_cache import MemoryReplayCache, RedisReplayCache +from authsome.server.ui_sessions import MemoryPendingClaimStore, RedisPendingClaimStore + + +class FakeRedisClient: + last_created = None + + 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() + cls.last_created = client + 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 + + +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 + + 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__ + + 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_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 + + 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 + + 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 + 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): + 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" + 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) 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"])} 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"