diff --git a/.env.example b/.env.example index 27fa4e8..66ade66 100644 --- a/.env.example +++ b/.env.example @@ -28,5 +28,25 @@ CORS_ORIGINS=http://localhost:5173,https://kilianmc.com DATABASE_URL=postgresql://neondb_owner:REPLACE_ME@ep-example-pooler.eu-central-1.aws.neon.tech/neondb?sslmode=require DATABASE_URL_UNPOOLED=postgresql://neondb_owner:REPLACE_ME@ep-example.eu-central-1.aws.neon.tech/neondb?sslmode=require +# Auth. AUTH_SECRET signs the HS256 access tokens; anyone holding it can mint a token +# for any user, so it is the single most sensitive value in the project after the +# database URLs. Minimum 32 characters — server/settings.py refuses anything shorter so +# a placeholder cannot ship. Generate a real one with: +# +# python -c "import secrets; print(secrets.token_urlsafe(48))" +# +# It must ALSO be set in Vercel, for EVERY scope you deploy to (Production, Preview, +# Development) — .env is not read inside a Vercel deployment. Rotating it invalidates +# every outstanding access token, which is the intended behaviour, not a bug. +# The placeholder below is deliberately SHORTER than the 32-character minimum, so +# copying this file without editing it fails loudly at the first auth request instead of +# shipping a guessable key. Same `REPLACE_ME` convention as the URLs above. +AUTH_SECRET=REPLACE_ME + +# The refresh cookie's `Secure` attribute. Defaults to true and must stay true +# everywhere it is served over https. Set it to false ONLY for plain-http localhost: +# COOKIE_SECURE=false +# Anything other than 0/false/no/off leaves Secure switched on. + # Reminder: any variable named VITE_* is inlined into the client bundle at build # time and is therefore PUBLIC. Never use that prefix for a secret. diff --git a/.github/workflows/migrate.yml b/.github/workflows/migrate.yml new file mode 100644 index 0000000..cc232f1 --- /dev/null +++ b/.github/workflows/migrate.yml @@ -0,0 +1,101 @@ +# Alembic runner — MANUAL ONLY. +# +# WHY THERE IS NO `on: push` HERE, and why adding one would be a mistake: +# deploys in this project are automatic, migrations deliberately are not. If a +# migration ran on push it would race the deployment that pushed it, and the loser is +# decided by whichever finishes first — so the API can come up against a schema that is +# either not there yet or already changed underneath it. Migrations therefore run out of +# band, on a human's decision, following expand -> deploy -> contract. The FastAPI +# lifespan only READS `alembic_version` and warns; it never migrates. See CLAUDE.md, +# "Migrations run out-of-band". +# +# Nothing in this file may print a connection string. The repository is public and so +# are these logs: no `set -x`, no `echo "$DATABASE_URL"`, not even a masked prefix. +name: Migrate + +on: + workflow_dispatch: + inputs: + environment: + description: "Which database to act on" + type: choice + required: true + options: [dev, production] + action: + # `current` is first AND the default on purpose: clicking straight through the + # dialog without reading it performs a read-only revision check, not a DDL run. + description: "current = read the applied revision · upgrade = run migrations · history = list revisions" + type: choice + required: true + default: current + options: [current, upgrade, history] + seed: + description: "After a successful upgrade, run `python -m server.seed` (idempotent upsert)" + type: boolean + required: false + default: false + +permissions: + contents: read + +# Two migration runs against the same database must never overlap. Queued, not +# cancelled: cancelling mid-DDL is how a half-applied migration happens. +concurrency: + group: migrate-${{ inputs.environment }} + cancel-in-progress: false + +jobs: + # NOTE: deliberately NOT named `web`, `server` or `secrets`. Those three are required + # status checks in a GitHub ruleset and the names are load-bearing. + migrate: + runs-on: ubuntu-latest + + # GitHub environment protection applies here: `production` can require Kilian's + # approval before the job starts, and the connection strings are environment + # secrets, so a run against `dev` cannot reach production's database. + environment: ${{ inputs.environment }} + + env: + # Alembic uses the DIRECT (unpooled) endpoint. DDL and CREATE TYPE need a real + # session; through Neon's PgBouncer transaction-mode pooler a migration tends to + # HANG rather than error, which is far worse to diagnose. + DATABASE_URL_UNPOOLED: ${{ secrets.DATABASE_URL_UNPOOLED }} + # The seed step goes through `server.db`, which reads the pooled URL. + DATABASE_URL: ${{ secrets.DATABASE_URL }} + # Choice inputs are constrained, but they are still interpolated into a shell, so + # they are passed as environment variables and quoted rather than expanded inline. + ACTION: ${{ inputs.action }} + + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + # No `--all-groups`: alembic and the server package are runtime dependencies, and + # a migration run has no use for pytest, ruff or mypy. `--frozen` still fails if + # uv.lock is stale, so this runs exactly the versions CI and Vercel resolved. + - run: uv sync --frozen + + # The "before" half of the audit trail. For `action: current` this is the whole job. + - name: Applied revision (before) + run: uv run alembic current --verbose + + - name: Revision history + if: ${{ inputs.action == 'history' }} + run: uv run alembic history --verbose + + - name: Upgrade to head + if: ${{ inputs.action == 'upgrade' }} + run: uv run alembic upgrade head + + # The "after" half. The two together are the record of what this run actually + # moved, which matters because nothing else logs it. + - name: Applied revision (after) + if: ${{ inputs.action == 'upgrade' }} + run: uv run alembic current --verbose + + # The same seed module CI, local development and production all use. Upserts and + # never deletes, so re-running it is safe and cheap. + - name: Seed reference data + if: ${{ inputs.action == 'upgrade' && inputs.seed }} + run: uv run python -m server.seed diff --git a/CLAUDE.md b/CLAUDE.md index a98d5fa..53ac6ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,6 +278,58 @@ recorded it as a side effect, batch it.** - **Never cron-ping Neon to defeat autosuspend.** A 5-minute ping is ≈ **730 CU-hr/month** against a **100 CU-hr** allowance — the free tier is gone in ~4 days. - **No per-action telemetry rows.** +- **The Postgres rate limiter does NOT protect awake time — corrected 2026-08-13.** The + original plan's risk #4 named "hard rate-limit `POST /api/auth/demo`" as the mitigation + for the 100 CU-hr ceiling. **That wording is superseded and must not be restored from + the plan.** `server/auth/ratelimit.py` counts with an upsert **and commits before it + checks the limit** — so a request that gets a 429 has still written to Postgres and has + still restarted the five-minute autosuspend window. Rejected traffic costs the same + awake time as accepted traffic. (Do not invert it to check-then-count: that + reintroduces a read-then-write race, and rejected attempts must be counted or the limit + is trivially evaded.) The table is an **abuse** control — credential stuffing, address + probing, unbounded demo-token minting — and it is worth having for that. + **Awake-time protection for unauthenticated endpoints has to sit at the edge**, where + the request never reaches the function: a Vercel WAF rule on `/api/auth/*`. +- **The 400-hour ceiling, and the arithmetic nobody had written down.** 100 CU-hr/month + at the 0.25 CU floor is **400 awake-hours** against a 730-hour month, and **autosuspend + is fixed at 5 minutes and is NOT configurable on the Free plan** (paid plans can only + *disable* it, never shorten it — verified against Neon's scale-to-zero docs, + 2026-08-13). Each training session wakes the compute for roughly one 5-minute window + per burst of activity, so 400 hours works out to **~260 sessions/month**, i.e. **about + 20 users training 3×/week** before the free tier is exceeded. Per-user cost is already + close to the floor by design — batched Tier-2 writes, the CDN-cached library endpoint, + and stateless token verification mean an authenticated request usually touches nothing. + **Growth past ~20 users is solved by Neon's paid plan, not by restricting users.** Do + not respond to this number by adding quotas, trimming features or shortening token + lifetimes; the correct lever is $19/month. +- **⚠️ The demo endpoint's rate limit lives OUTSIDE this repository.** It is a **Vercel + WAF rule on `/api/auth/*` — 20 requests / 10 minutes / IP** (10 minutes is the Hobby + maximum window, and Hobby allows exactly one rate-limit rule per project). + **Deleting that WAF rule silently removes the only rate limit on demo-token minting, + and nothing in the codebase will hint that it is gone.** There is deliberately no + `DEMO` rule in `server/auth/ratelimit.py` — it was removed 2026-08-13 because + enforcing it was itself a Postgres write, so a rejected request cost the same awake + time as an accepted one. A bot at **one request per minute** keeps the compute awake + 100% of the time (~182 CU-hr/month) while sitting inside any limit that table could + express. + - **The remaining exposure, honestly:** `POST /api/auth/demo` now issues **zero SQL** + (no `Session` in its signature), so unlimited demo-token minting costs Vercel + invocations (1M/month free) and CPU, and **zero Neon time**. That is the whole point + of the change, and it is why unlimited minting is an acceptable worst case: the + resource that is actually scarce is untouched. + - **`x-forwarded-for` is NOT client-spoofable on Vercel — resolved 2026-08-13.** An + earlier draft of this file claimed a header-rotating attacker could get a fresh + bucket per request. That was wrong. Vercel's request-headers reference states: *"If + you are trying to use Vercel behind a proxy, we currently overwrite the + `X-Forwarded-For` header and do not forward external IPs. This restriction is in + place to prevent IP spoofing."* The platform sets the header to the real client IP + and there is exactly **one** entry, so leftmost and rightmost are the same string; + `x-real-ip` and `x-vercel-forwarded-for` are documented as identical to it. Verified + against , 2026-08-13. Two residual + facts, neither a change to make: a proxy *this project* puts in front of Vercel could + overwrite the header afterwards (`x-vercel-forwarded-for` is the documented escape + hatch, and there is no such proxy), and **locally** under bare `uvicorn` the header + is whatever the client sends, so the limiter is trivially bypassable in development. - `GET /api/library?v=` is user-independent and immutable per deploy — serve `public, s-maxage=31536000, immutable` with `staleTime: Infinity`. Zero DB time and zero invocations after the first request per deploy. @@ -326,7 +378,35 @@ line that appeared in the original plan: `server/seed.py` is the **single** seed module — CI, local work and production all call it, because a test fixture with hand-written rows tests a table production never has. It **upserts and never deletes**: user rows reference `grade.id`, so retiring a - grade is a deliberate migration, not a side effect of editing a tuple. + grade is a deliberate migration, not a side effect of editing a tuple. It also seeds + the **demo account** (`demo@climb-trainer.example`, `password_hash = NULL`), which is + deployment fixture data, not user data. +- **`DEMO_USER_ID` is pinned at 1 and is part of the data contract** — demo tokens carry + it as `sub` so `POST /api/auth/demo` needs no lookup. Changing it is a migration. The + seed inserts that id explicitly and therefore **repairs `app_user_id_seq`** afterwards + (monotonic `setval`); without that the first real registration collides on the primary + key and surfaces as a baffling 409 on someone's first sign-up. + +#### How to actually run one: `.github/workflows/migrate.yml` + +Actions → **Migrate** → *Run workflow*. Three inputs: + +- **`environment`** — `dev` or `production`. This selects the GitHub **environment**, so + `production`'s protection rules (approval) apply and each environment carries its own + connection secrets. A `dev` run therefore cannot reach production's database. +- **`action`** — **`current` is the default, and it is read-only**: it prints the applied + revision and stops. `upgrade` runs `alembic upgrade head` and prints `current` both + before and after, so the job log is the audit trail. `history` lists the revisions. +- **`seed`** — off by default; when on, runs `python -m server.seed` after a successful + upgrade. + +**Prerequisite:** the two GitHub environments (`dev`, `production`) must exist, each +with **`DATABASE_URL_UNPOOLED`** (the *direct* endpoint — Alembic uses this) and +**`DATABASE_URL`** (the *pooled* endpoint — the seed step uses this) as environment +secrets. Without them the job starts and fails on the first Alembic step. + +The workflow is `workflow_dispatch`-only and never prints a connection string; keep both +properties if you edit it. ### SQLite is disqualified for tests @@ -361,10 +441,11 @@ Non-negotiable. The realistic threat is bulk data extraction, not defacement. - **Demo mode is public by design, but read-only.** `POST /api/auth/demo` issues a short-lived token for a **seeded fake-data** demo user. Enforced two ways: `SET LOCAL transaction_read_only` on the demo path, **and** deny-by-default - middleware rejecting every mutating method for demo tokens. Hard **rate-limited** - (rate limiting lives in a Postgres `rate_limit` table — there are no background - workers). A route-enumeration test asserts **every** mutating route 403s for a demo - token. No real user data is ever seeded into demo. + middleware rejecting every mutating method for demo tokens. A route-enumeration test + asserts **every** mutating route 403s for a demo token. No real user data is ever + seeded into demo. **Its rate limit is a Vercel WAF rule, not a Postgres row** — see the + warning in the compute-budget section, and note the endpoint deliberately issues zero + SQL so that unlimited minting cannot cost Neon time. - **`/docs` and `/openapi.json` are OFF in production** — an OpenAPI schema is a map of the attack surface. See `_docs_enabled` in `server/app.py`. - **CORS is an allowlist, never `"*"`**, with a **startup assertion** that rejects `*` @@ -380,6 +461,61 @@ Non-negotiable. The realistic threat is bulk data extraction, not defacement. Public Suffix List, so a preview URL is genuinely **cross-site** and the cookie cannot work there. Previews fall back to demo mode. +### Auth implementation (PR #3) — where each piece lives + +`server/auth/` — `passwords.py`, `tokens.py`, `refresh.py`, `cookies.py`, +`ratelimit.py`, `deps.py`, `routes.py`. Each module's docstring carries its reasoning; +this is the map, not a substitute for reading them. + +**Two token shapes, and they are deliberately different things:** + +- **Access token** — HS256 JWT, claims `sub` / `scope` / `typ` / `iss` / `iat` / `exp`. + **3 h** for `user`, **1 h** for `demo`. Verified with `algorithms=["HS256"]` and an + explicit `require=[…]`, and **verification never touches the database** — that is what + keeps an authenticated request from waking Neon. Held **in memory** by the client. +- **Refresh token** — opaque, 32 random bytes, stored only as a **sha256 hex digest**, + 30-day lifetime, in an httpOnly `SameSite=Lax` host-only cookie scoped to + `path=/api/auth`. Rotated on every use, in a **family**; presenting an + already-rotated or revoked token **revokes the whole family** (`refresh.rotate`). + sha256 rather than argon2 because the token is already 256 bits of entropy. + `rotate()` reads its row **`FOR UPDATE`** — without the lock two simultaneous + presentations of the same token both pass the reuse check and reuse goes undetected, + which is the exact case the family mechanism exists for. + +**The public-route list is `PUBLIC_ROUTES` in `server/auth/deps.py`.** `enforce_auth` +is registered **once, application-wide** (`FastAPI(dependencies=[…])`), never per +router — opt-in fails open when someone forgets. Adding an endpoint protects it by +default; making it public is a visible line in that frozenset. +`tests/test_auth_routes_enumerated.py` walks every registered route and fails if one is +neither listed nor 401-ing. + +**Demo read-only is enforced twice**, per the rule above: `enforce_auth` 403s a +`demo`-scope token on every `POST/PUT/PATCH/DELETE` (sole exception: +`POST /api/auth/demo`, enumerated in `DEMO_WRITE_EXEMPT_ROUTES`), **and** +`get_request_session` issues `SET LOCAL transaction_read_only = on` for a demo +principal. A consequence for the auth UI: a client in demo mode must **drop its demo +token before calling login or register**, or those calls 403. + +`AUTH_SECRET` (≥32 chars) is read lazily via `server/settings.py::auth_secret()`, never +at import time, and must be set in Vercel for every scope. + +Rate limiting lives in the `rate_limit` table, keyed by an **HMAC of the subject** — the +raw IP or email is never stored. Three IP-keyed rules (`login` 10/15 min, `register` +3/hour, `refresh` 30/hour) plus **`login_account`, keyed on the attempted email**, because +a per-IP limit does nothing against an attacker spread across many addresses. There is +**no `demo` rule** — that one is a Vercel WAF rule instead (see the compute-budget +warning). `login` and `refresh` are generous on purpose and lowering them buys nothing: +each attempt is one write, so the Neon cost is identical at 3 or 30, and a tighter limit +only inconveniences someone mistyping a password. Login checks both of its buckets **in a +single `INSERT … ON CONFLICT` statement** (`enforce_all`), so the second dimension costs +no extra round trip and no extra Neon wake-up. `login_account` is 30/hour: an attacker +can deliberately hold a real user at 429, which is accepted — it self-heals within the +hour and is a **rate limit, never an account lockout** (no state disables an account). +The 429 is identical whichever bucket tripped, and the email counter increments for +addresses that do not exist, so it is not an account-existence oracle. It is an **abuse** +control only; see the correction in the compute-budget section for why it does not +protect awake time. + --- ## Injection defence and input minimisation (OWASP) diff --git a/README.md b/README.md index 6747920..5d25b5f 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Requires Node per `.nvmrc` (24) and [`uv`](https://docs.astral.sh/uv/) for Pytho # once npm --prefix web ci uv sync --all-groups -cp .env.example .env # then fill in the Neon URLs +cp .env.example .env # then fill in the Neon URLs and AUTH_SECRET ``` `.env` is **loaded automatically** by `server/settings.py`, so the API, `alembic`, @@ -107,6 +107,25 @@ entirely on Vercel so a stray `.env` can never shadow production config. Quote a value containing `&` (Neon appends `&channel_binding=require`) if you also shell-source the file. +The variables, all documented inline in `.env.example`: + +| Variable | What it is | +| ----------------------- | --------------------------------------------------------------------- | +| `CORS_ORIGINS` | Comma-separated allowlist. A `*` fails at startup. | +| `DATABASE_URL` | Neon **pooled** endpoint (host contains `-pooler`) — the app. | +| `DATABASE_URL_UNPOOLED` | Neon **direct** endpoint — Alembic only. | +| `AUTH_SECRET` | HS256 signing key for access tokens. **≥32 chars**, generate it. | +| `COOKIE_SECURE` | Optional. Defaults to `true`; set `false` only for http on localhost. | + +Generate a signing key with +`python -c "import secrets; print(secrets.token_urlsafe(48))"`. It has to be set in +Vercel too, for every scope you deploy to — `.env` is not read inside a deployment. +Nothing secret may ever carry a `VITE_` prefix: that prefix is inlined into the public +client bundle. + +Database migrations are **not** run from a laptop against production. Use the manual +**Migrate** workflow (Actions → Migrate); see `CLAUDE.md`. + Then run both halves — the API on `:8000`, the SPA on `:5173` with Vite proxying `/api` across so the two share an origin exactly as they do in production: diff --git a/migrations/versions/0002_auth.py b/migrations/versions/0002_auth.py new file mode 100644 index 0000000..46b95e2 --- /dev/null +++ b/migrations/versions/0002_auth.py @@ -0,0 +1,110 @@ +"""auth: app_user, auth_session (refresh families), rate_limit + +Revision ID: 0002 +Revises: 0001 +Create Date: 2026-08-13 + +The authentication schema: accounts, the refresh-token rotation families that back +`server/auth/refresh.py`, and the Postgres-backed fixed-window rate limiter. + +Hand-written, like 0001, because there is no Postgres on the machine this was written +on to autogenerate against. CI is what proves it: `alembic upgrade head` against a +throwaway `postgres:17-alpine`, then `alembic check` to prove it matches the models. + +Constraint and index names are spelled out explicitly and match what +`NAMING_CONVENTION` in server/models.py derives. If they drift, `alembic check` fails +in CI and a future `op.drop_constraint` has nothing stable to name. + +No enum type is created here: `is_demo` is a plain boolean and `scope` lives only in +the JWT, never in a column, so there is nothing for the `create_type=False` dance that +0001 needed. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0002" +down_revision: str | None = "0001" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "app_user", + sa.Column("id", sa.Integer(), nullable=False), + # 254 = the RFC 5321 maximum. Stored lowercased and stripped by every write + # path, because citext is not available here — see server/models.py. + sa.Column("email", sa.String(length=254), nullable=False), + # NULLABLE on purpose: the seeded demo account has no password and must never + # be reachable through /api/auth/login. + sa.Column("password_hash", sa.String(length=255), nullable=True), + sa.Column("is_demo", sa.Boolean(), server_default=sa.text("false"), nullable=False), + # TIMESTAMPTZ (never naive), defaulted by the database's clock rather than a + # serverless function's. + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id", name="pk_app_user"), + sa.UniqueConstraint("email", name="uq_app_user_email"), + ) + + op.create_table( + "auth_session", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("family_id", sa.Uuid(), nullable=False), + # sha256 hex of the opaque refresh token. The plaintext is never stored. + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column( + "issued_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("expires_at", sa.TIMESTAMP(timezone=True), nullable=False), + # Set on rotation. A token presented with this already set has been replayed. + sa.Column("rotated_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("revoked_at", sa.TIMESTAMP(timezone=True), nullable=True), + # CASCADE so deleting an account cannot leave live refresh tokens behind. + sa.ForeignKeyConstraint( + ["user_id"], + ["app_user.id"], + name="fk_auth_session_user_id_app_user", + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name="pk_auth_session"), + sa.UniqueConstraint("token_hash", name="uq_auth_session_token_hash"), + ) + # Revoking a whole family on reuse detection is one indexed UPDATE. + op.create_index("ix_auth_session_family_id", "auth_session", ["family_id"], unique=False) + op.create_index( + "ix_auth_session_user_id_family_id", + "auth_session", + ["user_id", "family_id"], + unique=False, + ) + + op.create_table( + "rate_limit", + # The composite PK IS the window, which is what lets the limiter be a single + # INSERT ... ON CONFLICT DO UPDATE with no read-then-write race. + sa.Column("bucket", sa.String(length=128), nullable=False), + sa.Column("window_start", sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column("count", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("bucket", "window_start", name="pk_rate_limit"), + ) + + +def downgrade() -> None: + op.drop_table("rate_limit") + op.drop_index("ix_auth_session_user_id_family_id", table_name="auth_session") + op.drop_index("ix_auth_session_family_id", table_name="auth_session") + op.drop_table("auth_session") + # Last: auth_session's foreign key references it. + op.drop_table("app_user") diff --git a/package.json b/package.json index 9af0af3..d59ae72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "climb-trainer", - "version": "1.1.0", + "version": "1.2.0", "private": true, "description": "Climbing training app — plan generator, guided session player, training diary", "engines": { diff --git a/pyproject.toml b/pyproject.toml index 1249766..996b620 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,10 +5,19 @@ description = "climb-trainer API — versioning lives in the root package.json" requires-python = ">=3.13" dependencies = [ "alembic==1.19.1", + # argon2id password hashing (server/auth/passwords.py). The reference implementation + # of the algorithm OWASP recommends for new password storage. + "argon2-cffi==25.1.0", + # Pydantic's EmailStr is a no-op stub without it — it raises at model definition + # time rather than validating, so this is a hard runtime dep, not an extra. + "email-validator==2.3.0", "fastapi==0.128.8", # psycopg3 (NOT asyncpg) — see server/db.py for why. The [binary] extra ships a # wheel with libpq bundled, so the Vercel Python image needs no system libpq. "psycopg[binary]==3.3.4", + # HS256 access tokens (server/auth/tokens.py). Verification is stateless on purpose, + # so it must never need the database. + "pyjwt==2.13.0", # A runtime dep, not a dev one, on purpose: server/settings.py imports it, and a # dev-only dep would make production depend on the Vercel guard never regressing. "python-dotenv==1.2.2", @@ -28,7 +37,7 @@ dev = [ ] [tool.setuptools] -packages = ["server", "server.domain"] +packages = ["server", "server.auth", "server.domain"] [tool.ruff] target-version = "py313" @@ -36,8 +45,10 @@ line-length = 100 [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "S", "ASYNC"] -# S101: pytest needs bare asserts. -per-file-ignores = { "tests/*" = ["S101"] } +# S101: pytest needs bare asserts. S105/S106: the auth tests contain obviously-fake +# passwords and hashes by necessity — flagging them would mean either silencing the rule +# line by line forever or, worse, making the fixtures look less fake. +per-file-ignores = { "tests/*" = ["S101", "S105", "S106"] } [tool.mypy] python_version = "3.13" diff --git a/server/app.py b/server/app.py index 69cf9b3..6c83e6f 100644 --- a/server/app.py +++ b/server/app.py @@ -3,11 +3,18 @@ Routing contract, validated in spike S0 — see the repo CLAUDE.md before changing `vercel.json`: `/api/*` reaches this app with the ORIGINAL path, and anything unmatched here must return FastAPI's own JSON 404, never the SPA's HTML. + +**Authentication is deny-by-default and is wired here, once**, as an application-level +dependency. Registering it per-router would fail open the first time someone forgot; +registering it here means a new endpoint is protected unless its `(method, path)` is +added to `PUBLIC_ROUTES` in `server/auth/deps.py`, in a diff a reviewer sees. """ -from fastapi import FastAPI +from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware +from server.auth.deps import enforce_auth +from server.auth.routes import router as auth_router from server.settings import app_version, get_settings settings = get_settings() @@ -22,6 +29,13 @@ docs_url="/api/docs" if _docs_enabled else None, redoc_url=None, openapi_url="/api/openapi.json" if _docs_enabled else None, + # Nothing here uses OAuth2 in Swagger, and the default registers an extra route at + # `/docs/oauth2-redirect` — outside `/api/*`, so it could never be reached through + # Vercel's rewrite anyway. Removing it keeps the registered route table equal to the + # routes that actually exist, which is what the enumeration test walks. + swagger_ui_oauth2_redirect_url=None, + # The deny-by-default gate. See server/auth/deps.py. + dependencies=[Depends(enforce_auth)], ) if settings.cors_origins: @@ -34,7 +48,14 @@ ) +app.include_router(auth_router) + + @app.get("/api/health") def health() -> dict[str, str]: - """Liveness only — deliberately leaks nothing about the deployment.""" + """Liveness only — deliberately leaks nothing about the deployment. + + Public (listed in `PUBLIC_ROUTES`) and DB-free: a health check that queried the + database would restart Neon's five-minute awake window on every probe. + """ return {"status": "ok"} diff --git a/server/auth/__init__.py b/server/auth/__init__.py new file mode 100644 index 0000000..12ca6f1 --- /dev/null +++ b/server/auth/__init__.py @@ -0,0 +1,18 @@ +"""Authentication: password hashing, tokens, refresh rotation, and the deny-by-default +dependency that enforces all of it. + +Split into small modules on purpose — the enforcement logic in `deps.py` is the part +that has to be readable at a glance, and burying it in a file that also does argon2 +parameters and cookie attributes is how a subtle hole gets reviewed past. + +- `passwords.py` argon2id hashing, and the timing-equalising dummy verify. +- `tokens.py` HS256 access tokens. Verification NEVER touches the database. +- `refresh.py` opaque refresh tokens, rotation, and reuse detection. +- `cookies.py` the one cookie this app sets, and why each attribute is what it is. +- `ratelimit.py` fixed-window counter in Postgres (there are no background workers). +- `deps.py` deny-by-default auth, demo read-only, and the request session. +- `routes.py` the `/api/auth/*` endpoints. + +Nothing here is imported by `server/db.py`; the dependency arrow points one way, so the +engine wiring stays usable by Alembic and the seed with no auth stack loaded. +""" diff --git a/server/auth/cookies.py b/server/auth/cookies.py new file mode 100644 index 0000000..3b7124d --- /dev/null +++ b/server/auth/cookies.py @@ -0,0 +1,87 @@ +"""The refresh cookie — the only cookie this application sets. + +Every attribute below is load-bearing. Each one is listed with the thing it prevents, +because "simplify the cookie options" is a plausible-looking change that quietly +removes a control. + +- **`httponly=True`** — JavaScript cannot read it, so a stored-XSS bug in the diary + notes cannot exfiltrate a 30-day credential. This is also why the *access* token + lives in memory and never in `localStorage`: in the federated mount `localStorage` + belongs to kilianmc.com, shared with the whole portfolio. + +- **NO `domain` attribute** — omitting it makes the cookie **host-only**. Setting + `Domain=kilianmc.com` would send it to the apex and to every other subdomain, + i.e. to `portfolio-shell` and any future project. CLAUDE.md is explicit about this. + +- **`path="/api/auth"`** — the cookie is only ever attached to the four endpoints that + need it. Nothing else in the API sees it, so nothing else can accidentally + authenticate from it. + +- **`secure`** from settings, defaulting to on. See `server/settings.py`. + +- **`samesite="lax"` — this is the CSRF defence, and it is sufficient here.** + A cross-site POST from `evil.example` carries no cookie under Lax, so an attacker + cannot drive `/api/auth/refresh` or `/api/auth/logout` from another origin. The + federated mount still works because `climb.kilianmc.com` and `kilianmc.com` share a + registrable domain: they are **cross-origin but same-site**, and SameSite is a + *site* rule, not an origin rule. A genuine attacker origin is cross-site and blocked. + (This is also why `*.vercel.app` previews cannot work — the Public Suffix List makes + every `*.vercel.app` host its own site, so a preview is cross-site to the apex. + Previews fall back to demo mode; that is expected, not a bug to fix.) + +- **No `__Host-` prefix.** It would be a free extra guarantee, but `__Host-` *requires* + `Path=/`, and scoping the cookie to `/api/auth` is worth more than the prefix: it + removes the cookie from every non-auth request entirely. + +Everything outside `/api/auth` authenticates with a Bearer access token held in memory +and attached explicitly by the client. A header the browser never sends automatically +has **no CSRF surface at all**, which is why there is no CSRF token anywhere in this +codebase — there is nothing for one to protect. +""" + +from typing import Final + +from fastapi import Request, Response + +from server.auth.refresh import REFRESH_TTL +from server.settings import cookie_secure + +REFRESH_COOKIE_NAME: Final = "ct_refresh" + +# Must match the router prefix in `routes.py`. A mismatch does not fail loudly — the +# browser simply stops sending the cookie and refresh appears to "randomly" 401. +REFRESH_COOKIE_PATH: Final = "/api/auth" + +_SAMESITE: Final = "lax" + + +def set_refresh_cookie(response: Response, token: str) -> None: + response.set_cookie( + key=REFRESH_COOKIE_NAME, + value=token, + max_age=int(REFRESH_TTL.total_seconds()), + path=REFRESH_COOKIE_PATH, + secure=cookie_secure(), + httponly=True, + samesite=_SAMESITE, + # No `domain=` — host-only. See the module docstring. + ) + + +def clear_refresh_cookie(response: Response) -> None: + """Expire the cookie. + + The attributes have to match the ones it was set with or the browser treats it as a + different cookie and leaves the original in place. + """ + response.delete_cookie( + key=REFRESH_COOKIE_NAME, + path=REFRESH_COOKIE_PATH, + secure=cookie_secure(), + httponly=True, + samesite=_SAMESITE, + ) + + +def read_refresh_cookie(request: Request) -> str | None: + return request.cookies.get(REFRESH_COOKIE_NAME) diff --git a/server/auth/deps.py b/server/auth/deps.py new file mode 100644 index 0000000..e87af1e --- /dev/null +++ b/server/auth/deps.py @@ -0,0 +1,208 @@ +"""Deny-by-default authentication, demo read-only enforcement, and the request session. + +## Why this is a single GLOBAL dependency + +`enforce_auth` is registered once, on the application +(`FastAPI(dependencies=[Depends(enforce_auth)])`), and therefore runs for **every** +route FastAPI serves. The alternative — a dependency per router, or a decorator per +endpoint — **fails open**: the failure mode of forgetting it is an unprotected endpoint +that behaves perfectly in every test anyone thought to write. CLAUDE.md states the rule +directly: authentication is required unless a route appears on an explicitly enumerated +public list, and a test walks every registered route to prove it. + +So the only way to make a route public is to add it to `PUBLIC_ROUTES` below, in a diff, +where a reviewer sees it. An unmatched or unrecognised route is **not** public. + +## Where a user id may come from + +`Principal.user_id`, taken from the verified token. Nowhere else. **Every query is +scoped by `user_id` from the token, never from a client-supplied id, path parameter or +body field** — IDOR is the realistic extraction risk in this product: a single unscoped +`WHERE id = :id` hands over every user's training history. That is why the `CurrentUser` +dependency exposes the principal and there is deliberately no dependency, helper or +Pydantic field anywhere that reads a user id out of a request. + +## Demo mode, enforced twice + +1. **Here** — a `demo`-scope token on any mutating method gets a 403 before the handler + runs. The only exception is `POST /api/auth/demo` itself, enumerated below. +2. **In the database** — `get_request_session` issues `SET LOCAL transaction_read_only` + for a demo principal, so even a handler that ignores rule 1 cannot write. + +Two layers because the first is a policy that a future refactor could route around, and +the second is the database refusing. CLAUDE.md asks for both. + +Note for PR #6 (the auth UI): because the demo ban covers *every* mutating route, a +client that is in demo mode must **drop its demo token before calling +`/api/auth/login` or `/api/auth/register`**, or those calls will 403. That is the +intended contract — a demo token has no business being attached to a real login. +""" + +from collections.abc import Iterator +from typing import Annotated, Final + +from fastapi import Depends, HTTPException, Request, status +from sqlalchemy import text +from sqlalchemy.orm import Session + +from server.auth.tokens import InvalidAccessTokenError, Principal, decode_access_token +from server.db import get_session + +# (method, path) pairs that do NOT require authentication. `path` is the route's +# registered template, exactly as FastAPI stores it — not the requested URL. +# +# Adding a line here is the only way to make an endpoint public, and it is the line a +# reviewer should stop on. `tests/test_auth_routes_enumerated.py` fails if any other +# route answers an unauthenticated request with anything but 401. +PUBLIC_ROUTES: Final[frozenset[tuple[str, str]]] = frozenset( + { + # Liveness. Deliberately touches no database — see server/db.py. + ("GET", "/api/health"), + # The auth endpoints themselves: you cannot present a token to get one. + ("POST", "/api/auth/register"), + ("POST", "/api/auth/login"), + # Authenticated by the refresh cookie, not by a Bearer token. + ("POST", "/api/auth/refresh"), + # Idempotent and must work with an expired or absent session. + ("POST", "/api/auth/logout"), + ("POST", "/api/auth/demo"), + # Swagger and the schema. Listed for completeness of the enumeration test: + # FastAPI registers these as plain Starlette routes, so application-level + # dependencies never run for them anyway, and both are switched OFF entirely in + # production (`_docs_enabled` in server/app.py) because an OpenAPI document is a + # map of the attack surface. + ("GET", "/api/docs"), + ("GET", "/api/openapi.json"), + } +) + +MUTATING_METHODS: Final[frozenset[str]] = frozenset({"POST", "PUT", "PATCH", "DELETE"}) + +# The single route a demo token is allowed to POST to: the one that mints it. Enumerated +# rather than pattern-matched, so widening it is a visible diff. +DEMO_WRITE_EXEMPT_ROUTES: Final[frozenset[tuple[str, str]]] = frozenset( + {("POST", "/api/auth/demo")} +) + +# A literal constant. There is no interpolation here and there must never be any — this +# is the one place in the codebase that passes a string to `text()`, and it passes a +# fixed one. Do not "parameterise" it; `SET LOCAL` takes no bind parameters anyway. +# +# `SET LOCAL`, never a bare `SET`: Neon's pooled endpoint is PgBouncer in transaction +# mode, where a session-level `SET` either leaks to the next borrower of the connection +# or is silently dropped. `SET LOCAL` is scoped to the transaction and works pooled. +_READ_ONLY_TRANSACTION: Final = text("SET LOCAL transaction_read_only = on") + +# One message for every authentication failure. The client is never told whether the +# token was missing, expired, malformed or forged — that difference is only useful to +# someone probing. +_UNAUTHENTICATED = "Not authenticated." +_DEMO_READ_ONLY = "Demo mode is read-only." + + +def _unauthenticated() -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=_UNAUTHENTICATED, + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def _bearer_token(header: str | None) -> str | None: + """Extract the credential from an `Authorization: Bearer ` header.""" + if header is None: + return None + scheme, _, token = header.partition(" ") + if scheme.lower() != "bearer": + return None + token = token.strip() + return token or None + + +def enforce_auth(request: Request) -> None: + """Application-wide gate. Runs before every endpoint and every route dependency. + + Reads the matched route from `request.scope["route"]` — routing has already happened + by the time dependencies are solved, so this is the route template FastAPI actually + selected, not a re-derived guess at one. + """ + route = request.scope.get("route") + path = getattr(route, "path", None) + if not isinstance(path, str): + # No matched route, or something that is not an HTTP route. Unreachable in + # normal operation (an unmatched path 404s before dependencies run), and + # treated as protected rather than public if it ever happens. + raise _unauthenticated() + + # Starlette answers HEAD from the GET handler, so the public list stays keyed on GET. + method = "GET" if request.method == "HEAD" else request.method + is_public = (method, path) in PUBLIC_ROUTES + + principal: Principal | None = None + token = _bearer_token(request.headers.get("authorization")) + if token is not None: + try: + principal = decode_access_token(token) + except InvalidAccessTokenError: + # A bad token on a public route is simply ignored; on a protected one it is + # indistinguishable from no token at all. + if not is_public: + raise _unauthenticated() from None + + # Recorded even when it is None, so `get_request_session` and `current_principal` + # never have to re-parse the header (and can never disagree with this decision). + request.state.principal = principal + + if ( + principal is not None + and principal.scope == "demo" + and method in MUTATING_METHODS + and (method, path) not in DEMO_WRITE_EXEMPT_ROUTES + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=_DEMO_READ_ONLY) + + if is_public: + return + + if principal is None: + raise _unauthenticated() + + +def current_principal(request: Request) -> Principal: + """The authenticated principal, for handlers that need it. + + `enforce_auth` has already run and already rejected the unauthenticated case, so the + `None` branch here is a wiring error (a handler asking for a principal on a route + listed as public), not a client error. + """ + principal = getattr(request.state, "principal", None) + if not isinstance(principal, Principal): + raise _unauthenticated() + return principal + + +CurrentUser = Annotated[Principal, Depends(current_principal)] + + +def get_request_session( + request: Request, + session: Annotated[Session, Depends(get_session)], +) -> Iterator[Session]: + """`get_session`, plus the database-level half of demo read-only enforcement. + + Wrapping rather than editing `server/db.py` keeps the dependency arrow pointing one + way: `db.py` stays importable by Alembic and the seed with no auth stack loaded. + + Executing `SET LOCAL` also opens the transaction, which is exactly what is wanted — + the flag has to be in place before the handler's first statement. It lasts until the + transaction ends, so a handler that commits mid-request drops it; nothing on the demo + path commits, and the 403 in `enforce_auth` is the layer that covers the case where + someone later writes one that does. + """ + principal = getattr(request.state, "principal", None) + if isinstance(principal, Principal) and principal.scope == "demo": + session.execute(_READ_ONLY_TRANSACTION) + yield session + + +RequestSession = Annotated[Session, Depends(get_request_session)] diff --git a/server/auth/passwords.py b/server/auth/passwords.py new file mode 100644 index 0000000..e73f865 --- /dev/null +++ b/server/auth/passwords.py @@ -0,0 +1,95 @@ +"""argon2id password hashing. + +## The profile is OWASP's, NOT the library's defaults + +`m=46 MiB, t=1, p=1` — the first of OWASP's recommended argon2id configurations. The +deviation that matters is **`p=1`**: `argon2-cffi` defaults to `p=4`, and four lanes +only buy anything on four cores. A Vercel serverless function has **1 vCPU**, so `p=4` +there is four lanes time-slicing one core — identical work, more scheduling, worse +latency, and no extra resistance. Setting it explicitly also means the hash cost stops +depending on whatever the library's defaults happen to be after an upgrade. + +Memory cost is expressed in **KiB** by `argon2-cffi`, hence `46 * 1024`. + +## Timing is part of the security boundary + +A login that returns fast for an unknown email and slow for a known one is an account +enumeration oracle — it tells an attacker which addresses are registered, which is +exactly the input a credential-stuffing run wants. `verify_dummy()` exists so the +unknown-email path pays the same argon2 cost as the wrong-password path. It is not +optional politeness; without it the generic 401 in `routes.py` is cosmetic. + +## Parameter migration + +`needs_rehash()` exposes argon2's rehash signal. `routes.py` acts on it during login, +where the plaintext is available and the request is already committing a row, so +raising the cost parameters later is a no-downtime change that migrates users as they +sign in. Nothing rehashes outside login — there is nowhere else the plaintext exists. +""" + +import secrets +from functools import lru_cache + +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError + +# OWASP argon2id profile #1. See the module docstring before changing any number: +# every one of these is a deliberate departure from the library default. +_HASHER = PasswordHasher( + time_cost=1, + memory_cost=46 * 1024, # KiB -> 46 MiB + parallelism=1, # 1 vCPU on Vercel; more lanes is pure latency here + hash_len=32, + salt_len=16, +) + + +def hash_password(password: str) -> str: + """Return the encoded argon2id hash (algorithm and parameters included in the string).""" + return _HASHER.hash(password) + + +def verify_password(encoded_hash: str, password: str) -> bool: + """`True` iff `password` matches. Never raises for a wrong password or a bad hash. + + A malformed stored hash is treated as "does not match" rather than a 500: it is a + data problem, and a 500 on a login attempt is itself an information leak. + """ + try: + return _HASHER.verify(encoded_hash, password) + except (VerifyMismatchError, VerificationError, InvalidHashError): + return False + + +def needs_rehash(encoded_hash: str) -> bool: + """Whether `encoded_hash` was produced with weaker parameters than the current profile.""" + try: + return _HASHER.check_needs_rehash(encoded_hash) + except InvalidHashError: + # Unparseable: it cannot be verified either, so there is nothing to migrate. + return False + + +@lru_cache(maxsize=1) +def _dummy_hash() -> str: + """A throwaway hash of a random string, computed once per process. + + Random rather than a constant so no hash of a known plaintext is ever baked into a + public repository. Lazy rather than module-level so importing this module — which + `server.app` does on every cold start — does not pay a 46 MiB argon2 run. + """ + return _HASHER.hash(secrets.token_urlsafe(32)) + + +def verify_dummy() -> None: + """Burn one argon2 verification so the unknown-email path costs what a real one does. + + Caveat, accepted deliberately: the *first* call in a fresh process also pays for + building the dummy hash, so one request per cold start is measurably slower. That + is noise against Neon's wake latency and a cold Python start, and the alternative — + hashing at import — would slow every cold start instead of one unlucky login. + """ + try: + _HASHER.verify(_dummy_hash(), "") + except (VerifyMismatchError, VerificationError, InvalidHashError): + pass diff --git a/server/auth/ratelimit.py b/server/auth/ratelimit.py new file mode 100644 index 0000000..2cadaf2 --- /dev/null +++ b/server/auth/ratelimit.py @@ -0,0 +1,345 @@ +"""Fixed-window rate limiting, counted in Postgres. + +## Why the database + +There is no Redis and there are no background workers here. A serverless function is +frozen between invocations and may be a different instance every time, so an in-process +counter is both per-instance and reset by every cold start — which is to say, not a rate +limit. The `rate_limit` table is the only shared, durable place available. + +## One statement, no race — and one statement for ALL of a route's buckets + +`INSERT ... ON CONFLICT (bucket, window_start) DO UPDATE SET count = rate_limit.count + 1 +RETURNING bucket, count` — a single atomic round trip. A read-then-write would let two +concurrent requests both see `count = limit - 1` and both proceed, which is exactly the +window a credential-stuffing script would find. Built with SQLAlchemy constructs and +bound parameters; no SQL is assembled from strings anywhere in this module. + +`enforce_all()` puts **several buckets in that same statement** as multiple VALUES rows. +That is the point of its existence: a route can be limited along two independent +dimensions for the cost of one round trip and one commit, so adding a control costs no +extra latency and no extra Neon wake-up. `RETURNING bucket` is what lets each returned +count be matched back to its own rule — row order is not guaranteed. + +## The bucket key never contains an IP or an email + +The key is `":"`, where the subject is a client +IP for the IP-keyed rules and the **normalised email** for the account-keyed one. We do +not need to know anyone's address — the only question ever asked is "is this bucket +hot?" — so storing either in the clear would be collecting personal data for no purpose. +Keyed HMAC rather than a bare hash, because the IPv4 space is small enough to enumerate +offline in seconds and so is a list of likely email addresses. + +## Two dimensions on login: source AND target + +The per-IP bucket stops one machine. It does nothing against an attacker spread across +many addresses, because each address starts with a fresh budget. `LOGIN_ACCOUNT` keys on +**the email being attempted**, so the limit binds to the *target* of the attack and +rotating IPs does not help. + +**The trade-off, stated plainly because it must not be quietly omitted:** a determined +attacker can hold a real user's login at 429 by deliberately burning that user's bucket. +That is accepted. The alternative is unlimited distributed guessing, which is worse; the +window **self-heals within the hour** with no admin action; and this is a **rate limit, +never an account lockout** — nothing here writes state that disables an account, which is +exactly why OWASP moved off lockout policies in the first place. + +**30 per hour** is the chosen number: high enough that a real person mistyping their +password over and over, on several devices, never reaches it, and low enough that +distributed guessing against one account is pointless. + +A 429 from either bucket is identical, and **the email counter increments for every +address attempted, existing or not** — so the response can never be used to learn +whether an account exists. + +## This is an ABUSE control. It is NOT a compute-budget control. + +Stated plainly because the original plan claimed otherwise, and the code shows why the +claim was wrong: `enforce()` performs the upsert **and commits** before it looks at the +limit. A request that receives a 429 has therefore already written to Postgres, and +Neon's five-minute autosuspend timer restarts on that write exactly as it would on a +successful call. A script hammering `/api/auth/demo` keeps the database awake at the +same rate whether it is being rejected or not. + +Do not "fix" that by checking before counting. Reading the counter and then incrementing +it reintroduces the read-then-write race described above, which is a worse bug, and +rejected attempts genuinely have to be counted or the limit is trivially evaded. + +What this module *does* buy: it stops credential stuffing against `login` and bulk +address probing against `register`. That is worth having, and it is all it is for. + +**Real protection for unauthenticated endpoints sits at the edge**, where a request never +reaches the function or the database at all — a **Vercel WAF rule on `/api/auth/*`**. +That is the control the compute budget actually depends on, it lives outside this +repository, and CLAUDE.md carries the warning about deleting it. This table is not a +substitute for it. + +The endpoint that made this most obvious — `POST /api/auth/demo` — was the one rule +removed outright rather than left in place looking protective. See the comment where the +rules are defined. + +## Which routes + +`login` and `register` are the obvious credential-attack surfaces, and `login` carries +the second, account-keyed bucket described above. `refresh` is bounded because a valid +rotation is a write. **`demo` is not here at all** — see the comment where the rules are +defined for why the rule was deleted rather than kept. + +`LOGIN_ACCOUNT` is applied to **login only**, deliberately: + +- Not `register` — an address can be registered exactly once, so an account-keyed limit + there constrains nothing an attacker would want to repeat. +- Not `refresh` or `demo` — neither request carries an email, so there is no target to + key on. + +## Purging + +`purge()` deletes windows that are long over. It is called **opportunistically** from +`enforce_all()` — throttled to roughly once an hour per warm instance, and only when a +brand new window is opened — because there is no cron and there must not be one: a +scheduled job that pings Neon is precisely the ~730 CU-hr/month mistake CLAUDE.md +forbids. Slightly late cleanup of a tiny table is a much better trade than a timer. +""" + +import hashlib +import hmac +import time +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Any, Final, cast + +from fastapi import HTTPException, Request, status +from sqlalchemy import CursorResult, delete +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.orm import Session + +from server.models import RateLimit +from server.settings import auth_secret + + +@dataclass(frozen=True, slots=True) +class Rule: + """`limit` requests per `window`, per subject, per `name`. + + The *subject* is whatever the rule keys on — a client IP for most of these, the + attempted email for `LOGIN_ACCOUNT`. `name` keeps the two namespaces apart, so the + same string used as two different kinds of subject can never share a bucket. + """ + + name: str + limit: int + window: timedelta + + +# Keyed on the client IP: stops one machine. +# +# LOGIN and REFRESH are deliberately GENEROUS, and lowering them buys nothing. Both are a +# single write, so the Neon cost of an attempt is the same five-minute wake whether we +# allow 3 of them or 30 — the limit does not change the cost, only who it inconveniences. +# Cutting LOGIN would punish a person mistyping their password on a phone and would not +# slow a real attacker, who is bounded by LOGIN_ACCOUNT and by the edge rule instead. +# If you are here to "tighten security" by lowering these: it does not work. Read the +# ABUSE-control section above first. +LOGIN: Final = Rule("login", limit=10, window=timedelta(minutes=15)) +REFRESH: Final = Rule("refresh", limit=30, window=timedelta(hours=1)) + +# REGISTER is the exception, and it is tight (3/hour, down from 5 on 2026-08-13). A real +# person registers ONCE, so a low ceiling costs a legitimate user nothing, while each +# attempt is a genuine expense: a full argon2 hash (46 MiB, ~1 vCPU) plus a row. +REGISTER: Final = Rule("register", limit=3, window=timedelta(hours=1)) + +# There is deliberately NO `DEMO` rule. It was removed on 2026-08-13 — do not add one +# back, and do not read its absence as an oversight. +# +# It could not work. Enforcing a limit here is itself a Postgres write, so a REJECTED +# demo request restarted Neon's five-minute autosuspend window exactly as an accepted one +# did: the control cost the very resource it existed to protect. The arithmetic, so nobody +# has to re-derive it — Neon Free is 100 CU-hr/month at the 0.25 CU floor, i.e. **400 +# awake-hours** against a 730-hour month, and autosuspend is fixed at 5 minutes and is +# NOT configurable on Free (paid plans can only disable it, not shorten it). A bot +# trickling **one request per minute** therefore keeps the compute awake 100% of the time, +# costs ~182 CU-hr/month, and exhausts the allowance on its own — while sitting +# comfortably inside any limit this table was permitted to configure. +# +# `POST /api/auth/demo` now issues ZERO SQL (no `Session` in its signature at all), so +# hammering it costs Vercel invocations and CPU but **no Neon time**. Its rate limit lives +# at the edge instead, as a **Vercel WAF rule on `/api/auth/*`** — which is OUTSIDE this +# repository. See the warning in CLAUDE.md's compute-budget section: deleting that WAF +# rule silently removes the only rate limit on demo-token minting, and nothing in the +# codebase will hint at it. + +# Keyed on the ATTEMPTED EMAIL, login only: stops an attacker spread across many +# machines, which the per-IP rule cannot. 30/hour is deliberately generous — see "Two +# dimensions on login" in the module docstring for the number and for the accepted +# trade-off (an attacker can hold a real user at 429; it self-heals hourly and is never +# an account lockout). +LOGIN_ACCOUNT: Final = Rule("login_account", limit=30, window=timedelta(hours=1)) + +# Rows older than this are dead weight; the newest window they could belong to closed +# long ago. Generous relative to the longest window above (1 h). +RETENTION: Final = timedelta(days=1) + +_PURGE_INTERVAL_SECONDS: Final = 3600.0 + +# Soft throttle for the opportunistic purge. Process-local by nature — a warm instance +# purges at most hourly, a fleet of cold starts purges a little more often. Both are +# fine; the point is only that nothing here runs on a timer. +_last_purge_at = 0.0 + + +def client_ip(request: Request) -> str: + """The client address, used ONLY as HMAC input — never stored, never echoed. + + **On Vercel this header is platform-set and NOT client-controllable.** Vercel's + request-headers reference is explicit about it: + + > If you are trying to use Vercel behind a proxy, we currently overwrite the + > `X-Forwarded-For` header and do not forward external IPs. This restriction is in + > place to prevent IP spoofing. + + So a value supplied by the caller is discarded and replaced with the real client IP, + and there is exactly **one** entry — which means leftmost and rightmost are the same + string and the choice between them does not exist. `x-real-ip` and + `x-vercel-forwarded-for` are documented as identical to this header. Verified against + , 2026-08-13. The `split(",")[0]` is + therefore defensive, not load-bearing. + + Two things that are true and worth knowing, neither of which is a change to make: + + - **A proxy that this project puts in FRONT of Vercel** could overwrite the header + after Vercel set it. `x-vercel-forwarded-for` is the documented escape hatch for + that case. There is no such proxy here, so this is a pointer, not a TODO. + - **Locally** (`uvicorn` with nothing in front) the header is whatever the client + sends, so the limiter is trivially bypassable in development. That is not a threat + — it is just a reason never to read local behaviour as production behaviour. + + The value is never reflected in a response (CLAUDE.md forbids that outright) and + never reaches a query as anything but HMAC input. + """ + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + first = forwarded.split(",")[0].strip() + if first: + return first + client = request.client + return client.host if client is not None else "unknown" + + +def bucket_key(rule: Rule, subject: str) -> str: + """`":"`. A plain value, bound as a parameter — not SQL text. + + The subject is an IP for the IP-keyed rules and the **normalised** (stripped, + lowercased) email for `LOGIN_ACCOUNT`. Normalising is the caller's job and it is not + optional: `Kilian@x.com` and `kilian@x.com` would otherwise get separate budgets, and + the effective limit multiplies by the number of case variants an attacker can type. + """ + fingerprint = hmac.new( + auth_secret().encode("utf-8"), + subject.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{rule.name}:{fingerprint}" + + +def window_start_for(rule: Rule, now: datetime) -> datetime: + """Floor `now` to the start of its fixed window. + + Fixed windows, not a sliding log: a sliding window needs a row per request, and this + table must stay tiny. The known weakness is a burst straddling a boundary allowing up + to 2x the limit briefly, which is an acceptable trade for one row per client per + window. + """ + seconds = int(rule.window.total_seconds()) + epoch = int(now.timestamp()) + return datetime.fromtimestamp(epoch - (epoch % seconds), tz=UTC) + + +def purge(session: Session, older_than: timedelta = RETENTION) -> int: + """Delete finished windows. Returns the number of rows removed. Does not commit.""" + cutoff = datetime.now(UTC) - older_than + # See the note in refresh.revoke_family: DML always produces a CursorResult, but + # `Session.execute` is only typed as `Result`. + result = cast( + CursorResult[Any], + session.execute(delete(RateLimit).where(RateLimit.window_start < cutoff)), + ) + return result.rowcount + + +def enforce(session: Session, request: Request, rule: Rule) -> None: + """Count this request against one IP-keyed rule. Convenience over `enforce_all`.""" + enforce_all(session, (rule, client_ip(request))) + + +def enforce_all(session: Session, *checks: tuple[Rule, str]) -> None: + """Count this request against every `(rule, subject)` pair, in ONE statement. + + Raises a single generic 429 if **any** bucket is over its limit, with `Retry-After` + taken from whichever tripped window ends **last** — so a client that waits it out is + clear of all of them at once. The response says nothing about which bucket tripped or + how many there are: on login one of them is keyed by the attempted email, and naming + it would turn the 429 into an account-existence oracle. + + **This commits.** The counters have to survive whatever the request does next — if + they were left to the endpoint's transaction, every rejected login would roll back its + own increment and the limiter would never trip. Call it before any other work in the + handler, so committing here cannot truncate a half-finished write. + """ + if not checks: # pragma: no cover - no caller does this; guards the empty VALUES + return + + now = datetime.now(UTC) + + # Keyed by bucket. Deduplicated because Postgres refuses an ON CONFLICT DO UPDATE + # that would touch the same row twice in one statement ("cannot affect row a second + # time"); distinct rules already produce distinct buckets, so this only guards + # against a caller passing the same pair twice. + planned: dict[str, tuple[Rule, datetime]] = { + bucket_key(rule, subject): (rule, window_start_for(rule, now)) for rule, subject in checks + } + + insert_statement = pg_insert(RateLimit).values( + [ + {"bucket": bucket, "window_start": window_start, "count": 1} + for bucket, (_, window_start) in planned.items() + ] + ) + counted = insert_statement.on_conflict_do_update( + index_elements=[RateLimit.bucket, RateLimit.window_start], + # Renders `count = rate_limit.count + 1`, evaluated by Postgres inside the + # statement — so two concurrent requests cannot both read the same old value. + set_={"count": RateLimit.count + 1}, + ).returning(RateLimit.bucket, RateLimit.count) + # RETURNING order is not guaranteed, which is why `bucket` comes back with the count: + # each count has to be compared against its OWN rule's limit. + results = session.execute(counted).all() + + exceeded_until: list[datetime] = [] + opened_a_window = False + for bucket, count in results: + rule, window_start = planned[bucket] + if count == 1: + opened_a_window = True + if count > rule.limit: + exceeded_until.append(window_start + rule.window) + + if opened_a_window: + _maybe_purge(session) + session.commit() + + if exceeded_until: + retry_after = max(1, int((max(exceeded_until) - now).total_seconds())) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many requests. Please wait and try again.", + headers={"Retry-After": str(retry_after)}, + ) + + +def _maybe_purge(session: Session) -> None: + global _last_purge_at + now = time.monotonic() + if now - _last_purge_at < _PURGE_INTERVAL_SECONDS: + return + _last_purge_at = now + purge(session) diff --git a/server/auth/refresh.py b/server/auth/refresh.py new file mode 100644 index 0000000..045be56 --- /dev/null +++ b/server/auth/refresh.py @@ -0,0 +1,192 @@ +"""Opaque refresh tokens, rotation, and reuse detection. + +## Opaque, not a JWT + +A refresh token is 32 bytes from `secrets.token_urlsafe`. It carries no claims and means +nothing on its own — its entire meaning is the row it matches. That is what makes it +revocable, which is precisely what the stateless access token in `tokens.py` is not. + +## sha256, deliberately NOT argon2 + +Passwords get argon2 because they are low-entropy secrets a human chose; the whole cost +parameter exists to make guessing them expensive. A refresh token is **256 bits of +CSPRNG output** — there is no dictionary, no guessing, and nothing for a work factor to +slow down. Running argon2 on every refresh would buy exactly zero security and spend +46 MiB and tens of milliseconds of a 1-vCPU function doing it. sha256 gives the one +property that is actually needed: a database dump is not a set of working credentials. + +## Families and reuse detection — the important part of this file + +Every login starts a *family*. Each refresh writes a new row in the same family and +stamps `rotated_at` on the one it replaces, so the family is a chain with exactly one +live link. + +If a token is presented whose row is **already rotated or revoked**, that chain has two +holders. Either the legitimate client replayed (it should not — rotation is atomic per +request) or someone captured the cookie. There is no way to tell which from the request, +so the safe response is to **revoke the entire family**: both the attacker and the real +user are logged out, and the real user's next login starts a clean family. Silently +issuing a new token instead would hand a thief an indefinitely renewable session. + +Detection only works if the two requests are **serialised**, so `rotate()` reads the row +with `SELECT ... FOR UPDATE`. See the comment at that line: without the lock, a +simultaneous replay is not detected at all — which is the case that matters most. + +## Lifetime + +30 days. Long enough that a returning user is not asked to log in every week, short +enough that an abandoned cookie stops working. Rotation means a token is normally in use +for hours, not weeks — the 30 days is the *idle* horizon. +""" + +import hashlib +import hmac +import secrets +import uuid +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Any, Final, cast + +from sqlalchemy import CursorResult, select, update +from sqlalchemy.orm import Session + +from server.models import AuthSession + +REFRESH_TTL: Final = timedelta(days=30) + +# 32 bytes -> 43 url-safe characters. Comfortably past the point where brute force is +# the attack anyone would choose. +_TOKEN_BYTES: Final = 32 + + +class RefreshRejectedError(Exception): + """The presented refresh token is unknown, expired, revoked, or replayed. + + One exception for all four, because the client is told the same thing in every case + — the distinction is useful to an attacker and to nobody else. + """ + + +@dataclass(frozen=True, slots=True) +class IssuedRefresh: + """The plaintext token (returned to the client ONCE) plus what the caller needs.""" + + token: str + user_id: int + family_id: uuid.UUID + expires_at: datetime + + +def digest(token: str) -> str: + """sha256 hex of a refresh token. The only form that is ever persisted.""" + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def issue(session: Session, user_id: int, family_id: uuid.UUID | None = None) -> IssuedRefresh: + """Mint a token and add its row. Starts a new family unless one is supplied. + + Does not commit — the caller owns the transaction, so the token row and whatever + else the request wrote land together or not at all. + """ + now = datetime.now(UTC) + token = secrets.token_urlsafe(_TOKEN_BYTES) + family = family_id if family_id is not None else uuid.uuid4() + expires_at = now + REFRESH_TTL + + session.add( + AuthSession( + user_id=user_id, + family_id=family, + token_hash=digest(token), + issued_at=now, + expires_at=expires_at, + ) + ) + session.flush() + return IssuedRefresh(token=token, user_id=user_id, family_id=family, expires_at=expires_at) + + +def rotate(session: Session, presented_token: str) -> IssuedRefresh: + """Validate, retire the presented token, and issue its successor in the same family. + + Raises `RefreshRejectedError` on anything unexpected. **On the replay path this + method writes** (it revokes the family) and then raises, so the caller must commit + before turning the exception into a 401 — a rolled-back revocation would leave the + stolen family live, which is the entire bug this function exists to prevent. + """ + presented_digest = digest(presented_token) + # `with_for_update()` is LOAD-BEARING — do not remove it to "save a lock". + # + # Without it, two requests presenting the SAME token race: both SELECT the row, + # both see `rotated_at IS NULL`, both pass the reuse check below, and both mint a + # successor. The family ends up with two live tokens and reuse is never detected — + # which is exactly the attacker-replays-while-the-victim-refreshes case this whole + # mechanism exists to catch. READ COMMITTED does not help: the second transaction + # re-reads the row when it UPDATEs, but the *decision* was already taken from the + # stale snapshot. `FOR UPDATE` serialises the two on the row, so the loser re-reads + # after the winner commits, sees `rotated_at` set, and correctly revokes the family. + # + # Row locks are transaction-scoped, so this works through PgBouncer's + # transaction-mode pooler (unlike a session-level advisory lock, which does not). + row = session.scalars( + select(AuthSession).where(AuthSession.token_hash == presented_digest).with_for_update() + ).one_or_none() + if row is None: + raise RefreshRejectedError("unknown refresh token") + + # The lookup above was an indexed equality match, so this comparison is redundant + # today. It is kept because it is the line that stays correct if the row is ever + # fetched by some other key (by family, say, when adding a device list) and the + # digests end up compared in Python, where `==` on a secret is a timing side channel. + if not hmac.compare_digest(row.token_hash, presented_digest): # pragma: no cover + raise RefreshRejectedError("digest mismatch") + + now = datetime.now(UTC) + + if row.rotated_at is not None or row.revoked_at is not None: + # REUSE DETECTED. See the module docstring: kill the whole chain, not just this + # link, because we cannot tell the thief from the victim and one of them has a + # currently-valid successor token. + revoke_family(session, row.family_id) + raise RefreshRejectedError("refresh token reuse detected; family revoked") + + if row.expires_at <= now: + raise RefreshRejectedError("refresh token expired") + + row.rotated_at = now + return issue(session, row.user_id, family_id=row.family_id) + + +def revoke_family(session: Session, family_id: uuid.UUID) -> int: + """Revoke every live token in a family. Returns how many rows were affected. + + Only touches rows that are not already revoked, so calling it twice is a no-op + rather than a rewrite of history. + """ + # `Session.execute` is typed as returning `Result`, which has no `rowcount`; a DML + # statement always yields a `CursorResult`, which does. The cast says that. + result = cast( + CursorResult[Any], + session.execute( + update(AuthSession) + .where(AuthSession.family_id == family_id, AuthSession.revoked_at.is_(None)) + .values(revoked_at=datetime.now(UTC)) + ), + ) + return result.rowcount + + +def revoke_presented(session: Session, presented_token: str) -> bool: + """Logout: revoke the family the presented token belongs to. Idempotent. + + Returns whether a matching row existed. An unknown token is not an error — logging + out with a stale or forged cookie must still clear the cookie and return success, + or logout becomes a way to probe which tokens are real. + """ + row = session.scalars( + select(AuthSession).where(AuthSession.token_hash == digest(presented_token)) + ).one_or_none() + if row is None: + return False + revoke_family(session, row.family_id) + return True diff --git a/server/auth/routes.py b/server/auth/routes.py new file mode 100644 index 0000000..c957acc --- /dev/null +++ b/server/auth/routes.py @@ -0,0 +1,320 @@ +"""The `/api/auth/*` endpoints. + +Every request body is a Pydantic model with `extra="forbid"` and bounded string lengths, +and every ORM object is built by **assigning fields explicitly**. Never +`AppUser(**payload)`: mass assignment from a client dict is how `is_demo` or a `user_id` +gets set by whoever asks nicely. + +## These routes use `get_session`, not `RequestSession` + +Deliberate. `RequestSession` applies `SET LOCAL transaction_read_only` for a demo +principal, and `POST /api/auth/demo` is the one route a demo token may still POST to. +That route now takes no session at all, but the distinction still matters for the others: +none of them run under a demo principal, and none should acquire a read-only transaction +by accident. Everything outside this module should use `RequestSession`. + +## Rate limiting comes first in every handler that has it + +`ratelimit.enforce` / `enforce_all` commit, so they must run before the handler starts +building anything it would be sad to have committed early. Being first in the body also +means the expensive work (argon2, in `register` and `login`) is never reached by a client +that is already over the limit. The only thing that may precede it is normalising the +email, which `login`'s account-keyed bucket needs as its subject. +""" + +from typing import Annotated, Final, Literal + +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from pydantic import BaseModel, ConfigDict, EmailStr, Field +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from server.auth import ratelimit, refresh +from server.auth.cookies import clear_refresh_cookie, read_refresh_cookie, set_refresh_cookie +from server.auth.deps import CurrentUser +from server.auth.passwords import hash_password, needs_rehash, verify_dummy, verify_password +from server.auth.tokens import Scope, issue_access_token +from server.db import get_session +from server.models import AppUser +from server.seed import DEMO_USER_ID + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + +# Plain session — see the module docstring for why these routes opt out of the +# demo read-only wrapper. +DbSession = Annotated[Session, Depends(get_session)] + +# One message for "no such account" and for "wrong password". Anything more specific is +# an account-enumeration oracle; `verify_dummy()` makes the *timing* match too. +_GENERIC_LOGIN_FAILURE: Final = "Incorrect email or password." + +# 254 is the RFC 5321 maximum and matches `app_user.email`. +_MAX_EMAIL_LENGTH: Final = 254 + +# A length floor and nothing else. NIST dropped composition rules (a digit, a symbol, +# mixed case) years ago: they push users towards `Password1!`, which is both weaker and +# more annoying than a long passphrase. 128 is an upper bound because argon2 hashes the +# whole input and an unbounded field is a cheap way to burn CPU. +_MIN_PASSWORD_LENGTH: Final = 12 +_MAX_PASSWORD_LENGTH: Final = 128 + + +def _normalise_email(value: str) -> str: + """Lowercase and strip. `app_user.email` has no `citext`, so this is the invariant. + + Every write and every lookup goes through here. If one path skips it, two accounts + differing only in case become possible and the unique constraint stops meaning what + it appears to mean. + """ + return value.strip().lower() + + +class RegisterRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + email: Annotated[EmailStr, Field(max_length=_MAX_EMAIL_LENGTH)] + password: Annotated[ + str, Field(min_length=_MIN_PASSWORD_LENGTH, max_length=_MAX_PASSWORD_LENGTH) + ] + + +class LoginRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + # Bounded, but with no minimum: enforcing the registration floor here would tell an + # attacker the password policy from a 422, and would reject legitimate users whose + # password predates a future policy change. + email: Annotated[EmailStr, Field(max_length=_MAX_EMAIL_LENGTH)] + password: Annotated[str, Field(min_length=1, max_length=_MAX_PASSWORD_LENGTH)] + + +class TokenResponse(BaseModel): + """The access token, for the client to hold **in memory only**. + + Never `localStorage`: in the federated mount that storage belongs to kilianmc.com and + is shared with the whole portfolio (CLAUDE.md). The refresh token is not in this body + at all — it is the httpOnly cookie. + """ + + access_token: str + # The OAuth 2.0 `token_type` value, not a credential — ruff's S105 keys off the + # word "token" in the field name, hence the suppression. + token_type: Literal["bearer"] = "bearer" # noqa: S105 + expires_in: int + scope: Scope + + +class MeResponse(BaseModel): + user_id: int + scope: Scope + + +class LogoutResponse(BaseModel): + status: Literal["ok"] = "ok" + + +def _token_response(user_id: int, scope: Scope) -> TokenResponse: + issued = issue_access_token(user_id, scope) + return TokenResponse( + access_token=issued.token, expires_in=issued.expires_in, scope=issued.scope + ) + + +@router.post("/register", status_code=status.HTTP_201_CREATED) +def register( + payload: RegisterRequest, + request: Request, + response: Response, + session: DbSession, +) -> TokenResponse: + """Create an account, log it in, and start a refresh family. + + **A duplicate email returns 409, and that is a considered trade-off.** The textbook + anti-enumeration answer is a generic "check your inbox" that reveals nothing — but it + only works when there IS an inbox step. This product has no email verification, so a + generic response would leave a real person staring at a form that appears to have + worked while no account exists, with no way to discover that they already have one. + Being honest here is worth more than hiding a fact that `/api/auth/login` timing and + a password-reset flow would eventually expose anyway. **Rate limiting is the + mitigation**: `REGISTER` is 5 per hour per client, which makes enumerating a list of + addresses impractical. + """ + ratelimit.enforce(session, request, ratelimit.REGISTER) + + email = _normalise_email(payload.email) + if session.scalar(select(AppUser.id).where(AppUser.email == email)) is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="That email is already registered." + ) + + # Explicit assignment, never `AppUser(**payload.model_dump())` — `is_demo` is set + # here and must not be settable by the request. + user = AppUser(email=email, password_hash=hash_password(payload.password), is_demo=False) + session.add(user) + try: + session.flush() + except IntegrityError: + # Lost the race against a concurrent registration of the same address. The + # SELECT above is the friendly path; the unique constraint is the correct one. + session.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="That email is already registered." + ) from None + + issued = refresh.issue(session, user.id) + session.commit() + + set_refresh_cookie(response, issued.token) + return _token_response(user.id, "user") + + +@router.post("/login") +def login( + payload: LoginRequest, + request: Request, + response: Response, + session: DbSession, +) -> TokenResponse: + """Exchange credentials for an access token and a fresh refresh family.""" + # Normalised first because the account-keyed bucket below keys on the normalised + # form — `Kilian@x.com` and `kilian@x.com` must share one budget. + email = _normalise_email(payload.email) + + # TWO buckets, ONE statement: by client IP (stops one machine hammering) and by the + # ATTEMPTED EMAIL (stops the same attack spread across many machines, which the + # per-IP rule cannot). The email counter increments for every address tried, whether + # or not it exists, so the resulting 429 is identical either way and can never be + # used to discover whether an account exists. + ratelimit.enforce_all( + session, + (ratelimit.LOGIN, ratelimit.client_ip(request)), + (ratelimit.LOGIN_ACCOUNT, email), + ) + + user = session.scalars(select(AppUser).where(AppUser.email == email)).one_or_none() + + # A NULL `password_hash` is the seeded demo account: it has no password and can + # never be logged into this way. Same branch as "no such user", same generic 401. + if user is None or user.password_hash is None: + # Equalise the response time so this path is not distinguishable from a wrong + # password. Without it the generic message below is decoration. + verify_dummy() + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=_GENERIC_LOGIN_FAILURE) + + if not verify_password(user.password_hash, payload.password): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=_GENERIC_LOGIN_FAILURE) + + if needs_rehash(user.password_hash): + # The only moment the plaintext exists, and the request is committing anyway — + # so raising the argon2 parameters later migrates users as they sign in, with no + # extra round trip and no downtime. + user.password_hash = hash_password(payload.password) + + issued = refresh.issue(session, user.id) + session.commit() + + set_refresh_cookie(response, issued.token) + return _token_response(user.id, "user") + + +@router.post("/refresh") +def refresh_tokens( + request: Request, + response: Response, + session: DbSession, +) -> TokenResponse: + """Rotate the refresh cookie and mint a new access token. + + The client calls this **lazily, only after a 401** — never on a timer. A periodic + refresh is a periodic database write, which is the largest avoidable consumer of the + compute budget (CLAUDE.md). + """ + presented = read_refresh_cookie(request) + if presented is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated.") + + ratelimit.enforce(session, request, ratelimit.REFRESH) + + try: + issued = refresh.rotate(session, presented) + except refresh.RefreshRejectedError: + # `rotate` may have revoked an entire family on the reuse path. That revocation + # is a WRITE and it has to be committed before the 401 goes out — rolling it back + # would leave the stolen chain live, which is the whole point of detecting reuse. + session.commit() + clear_refresh_cookie(response) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated." + ) from None + + session.commit() + set_refresh_cookie(response, issued.token) + return _token_response(issued.user_id, "user") + + +@router.post("/logout") +def logout(request: Request, response: Response, session: DbSession) -> LogoutResponse: + """Revoke the presented refresh family and clear the cookie. + + Idempotent, and never an error: no cookie, an expired cookie or a forged one all + return the same success. A logout that could fail would be a way to probe which + tokens are real, and a client stuck unable to log out is worse than useless. + """ + presented = read_refresh_cookie(request) + if presented is not None: + refresh.revoke_presented(session, presented) + session.commit() + clear_refresh_cookie(response) + return LogoutResponse() + + +@router.post("/demo") +def demo() -> TokenResponse: + """Issue a 1-hour, read-only token for the seeded demo account. **Issues ZERO SQL.** + + ## The empty signature is the security control + + Note what is *not* a parameter: there is no `Session`, no `Request`, nothing that can + reach the database. That is deliberate and structural — zero-DB holds **by + construction**, not by a test or a convention, and reintroducing a query means adding + a dependency back to this line, which is a visible diff a reviewer will stop on. + Do not "just look up the demo user", do not cache or memoise a lookup: the handler + must remain unable to query. `DEMO_USER_ID` is pinned in `server/seed.py` precisely so + the token's `sub` needs no lookup. + + ## Why, with the arithmetic + + Neon Free is 100 CU-hr/month at the 0.25 CU floor = **400 awake-hours** in a 730-hour + month, and autosuspend is fixed at 5 minutes (not configurable on Free). A bot + trickling **one request per minute** at a DB-touching public endpoint therefore keeps + the compute awake 100% of the time, costs ~182 CU-hr/month and busts the whole + allowance by itself — while staying inside any rate limit we are able to configure. + The old Postgres rate limit could not stop that, because enforcing it was itself a + write. So the query is gone instead, and the rate limit moved to a **Vercel WAF rule + on `/api/auth/*`**. Unlimited minting now costs invocations and CPU, and zero Neon + time, which is what makes it an acceptable worst case. + + ## Consequence: the 503 is gone, and that is on purpose + + This used to return 503 when the seed had not run. It cannot detect that any more, so + **demo mode always issues a token** — against an unseeded database the user simply + sees empty data. That is the better failure: the endpoint stays up, and an empty demo + is a visibly wrong deployment rather than a broken one. Nobody should have to discover + it, hence this paragraph. + """ + return _token_response(DEMO_USER_ID, "demo") + + +@router.get("/me") +def me(principal: CurrentUser) -> MeResponse: + """The authenticated principal, straight from the verified token. + + **Touches no database at all.** Two reasons, both in CLAUDE.md: access-token + verification is stateless precisely so an authenticated request does not wake Neon, + and there must be no `last_seen` / `last_used_at` column — a write-per-read is the + classic accident that defeats every other compute rule here. Profile data (email, + target grade, settings) belongs to the profile endpoint in a later PR, where reading + it is the point of the request. + """ + return MeResponse(user_id=principal.user_id, scope=principal.scope) diff --git a/server/auth/tokens.py b/server/auth/tokens.py new file mode 100644 index 0000000..3d2bb01 --- /dev/null +++ b/server/auth/tokens.py @@ -0,0 +1,141 @@ +"""Access tokens: HS256 JWTs, verified WITHOUT touching the database. + +## Statelessness is the whole point + +Every authenticated request presents one of these. If validating one required a lookup, +every request would wake Neon — and Neon bills awake time, not queries (see the +compute-budget section of CLAUDE.md). So verification is signature + claims only. The +cost is that a token cannot be revoked before it expires; the refresh family in +`refresh.py` is where revocation actually lives, and the short-ish access lifetime is +what bounds the window. + +## Why three hours, and not fifteen minutes + +Refresh rotation is a database **write**. A 15-minute access token means a write every +15 minutes for as long as the app is open — across a 45-90 minute training session, plus +planning and diary time, that is the single largest consumer of the free tier's compute +allowance, spent entirely on ceremony. **3 hours** covers a session end to end with one +refresh at most, and the client refreshes lazily (only after a 401), never on a timer. +This is a recorded decision in CLAUDE.md, not an oversight. Demo tokens get **1 hour** +and no refresh at all, because a demo session is a few minutes of looking around. + +## Claims, and why `typ` exists + +`sub` (user id as a string, per RFC 7519), `scope`, `iat`, `exp`, `iss`, and `typ`. +`typ` is `"access"` and is *required*: today it is the only kind of JWT this app mints, +but the moment a second one exists (an email-verification link, a share token) the +absence of a type claim makes each one accepted wherever the other is — a confused +deputy that is free to prevent now and expensive to retrofit. + +## `algorithms=["HS256"]` is explicit, always + +Decoding without pinning the algorithm list lets the *token* choose, which is the +`alg: none` forgery and the HMAC-vs-RSA confusion attack in one. `require=[...]` closes +the matching hole at the claim level: a token that simply omits `exp` must not be +treated as one that never expires. +""" + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Final, Literal, cast + +import jwt + +from server.settings import auth_secret + +Scope = Literal["user", "demo"] + +ISSUER: Final = "climb-trainer" +ALGORITHM: Final = "HS256" +# The value of the `typ` claim, not a secret — ruff's S105 only sees "TOKEN" in the name. +TOKEN_TYPE: Final = "access" # noqa: S105 + +# See the module docstring. These are budget decisions as much as security ones. +USER_TOKEN_TTL: Final = timedelta(hours=3) +DEMO_TOKEN_TTL: Final = timedelta(hours=1) + +_TTL_BY_SCOPE: Final[dict[Scope, timedelta]] = { + "user": USER_TOKEN_TTL, + "demo": DEMO_TOKEN_TTL, +} + +# Every claim the verifier depends on. A token missing any of them is rejected outright +# rather than defaulting — an absent `exp` must never read as "no expiry". +_REQUIRED_CLAIMS: Final[list[str]] = ["sub", "scope", "iat", "exp", "iss", "typ"] + + +class InvalidAccessTokenError(Exception): + """The token is absent, malformed, expired, tampered with, or of the wrong type. + + Deliberately one exception for all of those: the caller turns it into a single + generic 401, so the failure reason never reaches the client and becomes a probe. + """ + + +@dataclass(frozen=True, slots=True) +class Principal: + """Who the request is, as proven by the token — and nothing else. + + `user_id` here is the ONLY acceptable source of a user id for a query. Never take + one from a path parameter, a query string or a request body: an unscoped + `WHERE id = :id` is the IDOR that hands over every user's training history. + """ + + user_id: int + scope: Scope + + +@dataclass(frozen=True, slots=True) +class AccessToken: + token: str + expires_in: int + scope: Scope + + +def issue_access_token(user_id: int, scope: Scope) -> AccessToken: + now = datetime.now(UTC) + ttl = _TTL_BY_SCOPE[scope] + payload = { + # RFC 7519 says `sub` is a string; PyJWT enforces it. The int round-trips in + # `decode_access_token`, which is also where a non-numeric `sub` is rejected. + "sub": str(user_id), + "scope": scope, + "typ": TOKEN_TYPE, + "iss": ISSUER, + "iat": now, + "exp": now + ttl, + } + token = jwt.encode(payload, auth_secret(), algorithm=ALGORITHM) + return AccessToken(token=token, expires_in=int(ttl.total_seconds()), scope=scope) + + +def decode_access_token(token: str) -> Principal: + """Verify and unpack a token. Raises `InvalidAccessTokenError` for every failure. + + Does not query the database, and must not start: see the module docstring. + """ + try: + claims = jwt.decode( + token, + auth_secret(), + # Pinned, never read from the token's own header. + algorithms=[ALGORITHM], + issuer=ISSUER, + options={"require": _REQUIRED_CLAIMS}, + ) + except jwt.PyJWTError as exc: + raise InvalidAccessTokenError("access token rejected") from exc + + if claims.get("typ") != TOKEN_TYPE: + raise InvalidAccessTokenError("wrong token type") + + scope = claims.get("scope") + if scope not in ("user", "demo"): + raise InvalidAccessTokenError("unknown scope") + + try: + user_id = int(claims["sub"]) + except (TypeError, ValueError) as exc: + raise InvalidAccessTokenError("malformed subject") from exc + + return Principal(user_id=user_id, scope=cast(Scope, scope)) diff --git a/server/models.py b/server/models.py index be7d56b..9b000b7 100644 --- a/server/models.py +++ b/server/models.py @@ -17,10 +17,12 @@ migrate. Store aware, convert at the edge. """ +import uuid from datetime import datetime from sqlalchemy import ( TIMESTAMP, + Boolean, Enum, ForeignKey, Index, @@ -29,6 +31,8 @@ SmallInteger, String, UniqueConstraint, + Uuid, + func, ) from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship @@ -111,3 +115,91 @@ class Grade(Base): # send pyramid's `(user_id, grade_ordinal)` joins both come in on ordinal. Index("ix_grade_ordinal", "ordinal"), ) + + +class AppUser(Base): + """An account. Named `app_user` because `user` is a reserved word in Postgres. + + `email` is `String(254)` (the RFC 5321 maximum) rather than `citext`: the extension + is not guaranteed on Neon and adding one is a privileged operation, so + case-insensitivity is enforced at the edge instead — every write path lowercases and + strips before it touches this column, and the unique constraint then means what it + looks like it means. If a future path forgets to normalise, two accounts differing + only in case become possible; that is the failure this comment exists to prevent. + + `password_hash` is **nullable on purpose**. The seeded demo account has no password + and must never be loggable through `/api/auth/login`; a NULL here is what makes that + structural rather than a check the login handler could forget. + """ + + __tablename__ = "app_user" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + email: Mapped[str] = mapped_column(String(254), unique=True) + # argon2id encoded hash ("$argon2id$v=19$m=47104,t=1,p=1$..."), ~100 chars at the + # profile in server/auth/passwords.py. 255 leaves room for a future parameter bump. + password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) + is_demo: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=func.false()) + # `Mapped[datetime]` picks up TIMESTAMPTZ from `type_annotation_map` above. The + # default is a SERVER default so the timestamp is the database's clock, not a + # serverless function's — the two disagree often enough to matter for ordering. + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + + +class AuthSession(Base): + """One refresh token in a rotation family. See `server/auth/refresh.py`. + + A *family* is the chain of refresh tokens descending from one login. Every rotation + writes a new row with the same `family_id` and stamps `rotated_at` on the old one, + so the family is an append-only audit trail of a single browser's session. + + That trail is what makes **reuse detection** possible: a token whose row already has + `rotated_at` (or `revoked_at`) set has been presented twice, which only happens if it + was captured. The response is to revoke the whole family — the legitimate holder is + logged out too, which is the correct trade when the alternative is leaving an + attacker with a valid chain. + + `token_hash` stores a **sha256 hex digest, never the token**. A database dump must + not be a set of working credentials. + """ + + __tablename__ = "auth_session" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + # ondelete=CASCADE: deleting an account must not leave live refresh tokens behind, + # and this is one of the few places where the database can guarantee that itself. + user_id: Mapped[int] = mapped_column(ForeignKey("app_user.id", ondelete="CASCADE")) + family_id: Mapped[uuid.UUID] = mapped_column(Uuid()) + # 64 hex characters, exactly — sha256. Unique because two rows sharing a digest + # would make rotation ambiguous, and because it is the lookup key on every refresh. + token_hash: Mapped[str] = mapped_column(String(64), unique=True) + issued_at: Mapped[datetime] = mapped_column(server_default=func.now()) + expires_at: Mapped[datetime] = mapped_column() + rotated_at: Mapped[datetime | None] = mapped_column(nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(nullable=True) + + __table_args__ = ( + # Revoking a compromised family is a single indexed UPDATE. + Index("ix_auth_session_family_id", "family_id"), + # "this user's live sessions", for a future device list and for logout-everywhere. + Index("ix_auth_session_user_id_family_id", "user_id", "family_id"), + ) + + +class RateLimit(Base): + """Fixed-window request counter. See `server/auth/ratelimit.py`. + + It lives in Postgres because there are no background workers and no Redis in this + deployment — a serverless function is frozen between requests, so an in-process + counter would reset on every cold start and be per-instance even when warm. + + The primary key IS the window: `(bucket, window_start)`. That makes the whole limiter + one `INSERT ... ON CONFLICT DO UPDATE ... RETURNING count`, i.e. one round trip with + no read-then-write race. `bucket` never contains a raw IP — see the module for why. + """ + + __tablename__ = "rate_limit" + + bucket: Mapped[str] = mapped_column(String(128), primary_key=True) + window_start: Mapped[datetime] = mapped_column(primary_key=True) + count: Mapped[int] = mapped_column(Integer, nullable=False) diff --git a/server/seed.py b/server/seed.py index 63b622d..e00c789 100644 --- a/server/seed.py +++ b/server/seed.py @@ -1,4 +1,4 @@ -"""Reference-data seed — the grade ladder. +"""Reference-data seed — the grade ladder, and the demo account. **This is the single seed module: CI, local development and production all call it.** That is deliberate. A test fixture that seeds its own hand-written rows tests a table @@ -14,19 +14,48 @@ someone's training history. Retiring a rung is therefore a deliberate migration, not a side effect of editing a tuple. +The **demo account** is seeded here too. It is deployment fixture data, not user data: +demo-scope tokens carry its id, and that id has to mean the same thing in CI and in +production for the same reason the grade ladder does. + All statements are SQLAlchemy constructs with bound parameters — no string-built SQL -anywhere, per the injection rules in CLAUDE.md. +anywhere, per the injection rules in CLAUDE.md. That includes the sequence repair below, +which looks like raw SQL but is `func.setval(...)` with bound values throughout. """ from dataclasses import dataclass -from sqlalchemy import select +from sqlalchemy import func, null, select from sqlalchemy.dialects.postgresql import insert from sqlalchemy.orm import Session from server.db import session_scope from server.domain import grades -from server.models import Grade, GradeSystem +from server.models import AppUser, Grade, GradeSystem + +# The `.example` TLD is reserved by RFC 2606 and can never be registered, so this +# address is unmistakably not a person's. (`.invalid` would be equally fake but +# `email-validator` rejects it outright, which would make the "the demo account can +# never be logged into" path a 422 instead of exercising the NULL-hash branch.) +# **No real user data is ever seeded into demo** — the rich fake training history the +# demo mount shows off is PR #18's job, and it will be generated, not copied from anyone. +DEMO_USER_EMAIL = "demo@climb-trainer.example" + +# **A PINNED id, and therefore part of the data contract** — changing it is a migration, +# not an edit. `POST /api/auth/demo` puts this straight into the token's `sub` so that +# the endpoint can issue a demo token with **zero SQL**; see the route's docstring for +# why that matters (a bot trickling one request a minute at a DB-touching endpoint keeps +# Neon awake around the clock and busts the whole free allowance). +DEMO_USER_ID = 1 + + +class DemoUserSeedError(RuntimeError): + """The pinned demo id or email is occupied by something that is not the demo account. + + Loud on purpose. The alternative — quietly repointing the demo token at whatever row + happens to hold id 1, or quietly rewriting a real person's email to the demo address + — is silent data loss in one direction and a privacy breach in the other. + """ @dataclass(frozen=True, slots=True) @@ -36,7 +65,7 @@ class SeedResult: def seed_reference_data(session: Session) -> SeedResult: - """Upsert every grade system and grade. Does NOT commit — the caller owns that.""" + """Upsert every grade system and grade, plus the demo account. Does NOT commit.""" system_stmt = insert(GradeSystem).values( [ {"key": spec.key.value, "name": spec.name, "discipline": spec.discipline} @@ -75,15 +104,113 @@ def seed_reference_data(session: Session) -> SeedResult: grade_stmt.on_conflict_do_update( # The natural key. Re-pointing a label at a different rung is the one edit # that must propagate, since everything comparable is derived from ordinal. + # + # KNOWN SHARP EDGE, left as-is: this handles a conflict on + # (grade_system_id, label), but `grade` also has a unique constraint on + # (grade_system_id, ordinal). Swapping two labels' ordinals in one edit + # therefore raises IntegrityError instead of upserting, because the + # intermediate state collides on the *other* constraint. That is a loud, + # correct failure — the fix is a migration, not a cleverer upsert — but do + # not add a second `on_conflict` here expecting it to cover both. index_elements=[Grade.grade_system_id, Grade.label], set_={"ordinal": grade_stmt.excluded.ordinal}, ) ) session.flush() + _seed_demo_user(session) + return SeedResult(grade_systems=len(grades.GRADE_SYSTEMS), grades=len(grades.GRADES)) +def _seed_demo_user(session: Session) -> None: + """Upsert the demo account at its pinned id. Idempotent, and it never deletes anything. + + `password_hash` is forced back to NULL on every run, not merely defaulted on insert. + That is the point: a NULL hash is what makes the demo account structurally + unloggable through `/api/auth/login`, so if anything ever sets one — a stray fixture, + a manual `UPDATE`, a future bug — the next seed run takes it away again. + + The id is written explicitly, which brings two hazards this function has to close. + """ + # HAZARD 1: id 1 belongs to somebody real. Only reachable if a registration beat the + # first seed run on a fresh database (the documented order is migrate -> seed -> + # deploy). The INSERT below would fail on the primary key anyway — Postgres refuses, + # so a real row can never be clobbered — but "duplicate key value violates + # pk_app_user" during a production seed is a terrible message to debug from. + occupant = session.scalars(select(AppUser).where(AppUser.id == DEMO_USER_ID)).one_or_none() + if occupant is not None and not occupant.is_demo: + raise DemoUserSeedError( + f"app_user.id {DEMO_USER_ID} is reserved for the demo account (DEMO_USER_ID) " + f"but belongs to a real account ({occupant.email!r}). Demo tokens carry that " + f"id, so seeding cannot continue. Resolve it deliberately: move the real row " + f"to a free id, or change DEMO_USER_ID in a migration." + ) + + statement = insert(AppUser).values( + id=DEMO_USER_ID, + email=DEMO_USER_EMAIL, + password_hash=None, + is_demo=True, + ) + session.execute( + # Conflict target stays the EMAIL, not the id. That way a pre-existing demo row + # is updated in place, while a collision on the *id* is left to raise — the one + # case where failing is the correct behaviour. + statement.on_conflict_do_update( + index_elements=[AppUser.email], + set_={"password_hash": null(), "is_demo": True}, + ) + ) + session.flush() + + seeded_id = session.scalar(select(AppUser.id).where(AppUser.email == DEMO_USER_EMAIL)) + if seeded_id != DEMO_USER_ID: + raise DemoUserSeedError( + f"the demo account exists at id {seeded_id}, not the pinned DEMO_USER_ID " + f"{DEMO_USER_ID}. Demo tokens would point at the wrong row. This happens on a " + f"database seeded before the id was pinned: delete the demo row " + f"({DEMO_USER_EMAIL}) and re-run the seed — it holds no user data." + ) + + _advance_user_id_sequence(session) + + +def _advance_user_id_sequence(session: Session) -> None: + """Push `app_user_id_seq` past the explicitly-inserted demo id. + + **Without this, the first real registration collides on the primary key** — an + explicit `INSERT ... (id) VALUES (1)` does not consume a sequence value, so `nextval` + still returns 1 and the new user hits `pk_app_user`. The symptom would be a + mysterious 409 on somebody's very first sign-up (the register handler maps + `IntegrityError` to "email already registered"), which is about as misleading as an + error can get. + + Monotonic by construction: the target is the greatest of the current sequence value, + the largest id present, and `DEMO_USER_ID`, so this can only ever move the sequence + **forward**. A plain `GREATEST(MAX(id), demo_id)` would *lower* it on a table with + gaps at the top, and handing out ids that an in-flight transaction has already taken + is not a class of bug worth risking to save a term. + + No raw SQL: `pg_get_serial_sequence`'s arguments are function *values*, not + identifiers, so they bind as ordinary parameters. + """ + sequence = func.pg_get_serial_sequence(AppUser.__tablename__, "id") + highest_id = select(func.max(AppUser.id)).scalar_subquery() + session.execute( + select( + func.setval( + sequence, + func.greatest( + func.coalesce(highest_id, 0), + func.coalesce(func.pg_sequence_last_value(sequence), 0), + DEMO_USER_ID, + ), + ) + ) + ) + + def main() -> None: """`uv run python -m server.seed` — run after `alembic upgrade head`. @@ -92,7 +219,9 @@ def main() -> None: """ with session_scope() as session: result = seed_reference_data(session) - print(f"seeded {result.grade_systems} grade systems, {result.grades} grades") + print( + f"seeded {result.grade_systems} grade systems, {result.grades} grades, and the demo account" + ) if __name__ == "__main__": diff --git a/server/settings.py b/server/settings.py index c98380a..aa229e2 100644 --- a/server/settings.py +++ b/server/settings.py @@ -115,6 +115,71 @@ def direct_database_url() -> str | None: return _env_or_none(DIRECT_URL_ENV) or pooled_database_url() +# --- Auth configuration ----------------------------------------------------------- +# +# Both are read LAZILY, never at import time, for the same two reasons as the database +# URLs: on Vercel an import-time failure takes the whole function down (including +# `/api/health` and the SPA's own error reporting), and the local SPA-only workflow has +# no auth at all — `npm run check` must stay green with neither variable set. +# These are variable NAMES, not values — the values never appear in this repository. +# The suppression below is because ruff's S105 flags any literal assigned to a name +# containing "SECRET", which is exactly what an env-var-name constant looks like. +AUTH_SECRET_ENV = "AUTH_SECRET" # noqa: S105 +COOKIE_SECURE_ENV = "COOKIE_SECURE" + +# 32 chars is the floor, not the recommendation. It exists to stop a placeholder +# ("changeme", "secret") reaching production, where every access token in the system +# would be forgeable. Generate the real one with the command in .env.example. +MIN_AUTH_SECRET_LENGTH = 32 + +# Only these exact values disable `Secure` on the refresh cookie. Anything else — +# including a typo — leaves it on, because the failure modes are asymmetric: a cookie +# that is too secure fails visibly on http, one that is not secure enough travels in +# clear text and nobody notices. +_EXPLICIT_FALSE = frozenset({"0", "false", "no", "off"}) + + +class AuthNotConfiguredError(RuntimeError): + """Raised when an auth operation needs `AUTH_SECRET` and it is missing or too short.""" + + +def auth_secret() -> str: + """The HS256 signing key for access tokens. Raises only when auth is actually used. + + Deliberately not `lru_cache`d: the miss costs one dict lookup, and caching would + freeze a value that tests (and a future secret rotation) need to be able to change. + """ + value = _env_or_none(AUTH_SECRET_ENV) + if value is None: + raise AuthNotConfiguredError( + f"{AUTH_SECRET_ENV} is not set. Generate one with " + f'`python -c "import secrets; print(secrets.token_urlsafe(48))"` and put it ' + f"in {DOTENV_PATH} (gitignored — this repo is public, so never commit a real " + f"value). On Vercel, .env is deliberately NOT read: set {AUTH_SECRET_ENV} in " + f"the project's environment variables, for every scope you deploy to." + ) + if len(value) < MIN_AUTH_SECRET_LENGTH: + raise AuthNotConfiguredError( + f"{AUTH_SECRET_ENV} is only {len(value)} characters; at least " + f"{MIN_AUTH_SECRET_LENGTH} are required. A short or placeholder secret makes " + f'every access token forgeable. Use `python -c "import secrets; ' + f'print(secrets.token_urlsafe(48))"`.' + ) + return value + + +def cookie_secure() -> bool: + """Whether the refresh cookie carries `Secure`. Defaults to True; opt out explicitly. + + Defaulting to True and requiring `COOKIE_SECURE=false` to disable it means the + insecure setting can only ever be reached on purpose. The reason it is configurable + at all is plain http on localhost: Safari's handling of `Secure` cookies on + `http://localhost` has changed more than once and is not something to bet the login + flow on, so local development gets an escape hatch. Production never sets it. + """ + return os.environ.get(COOKIE_SECURE_ENV, "").strip().lower() not in _EXPLICIT_FALSE + + @dataclass(frozen=True) class Settings: env: str = field(default_factory=lambda: os.environ.get("VERCEL_ENV", "development")) diff --git a/tests/conftest.py b/tests/conftest.py index 0d980ac..6fd9b8b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,23 +14,47 @@ session joins as a SAVEPOINT, and rolls the whole thing back afterwards. So tests see the seeded reference data, can write freely, and leave nothing behind — and the seed runs once per session rather than once per test. + +`AUTH_SECRET` is injected for the whole run by an autouse fixture, so the *pure* auth +tests (JWT shapes, the public-route table) execute in the local gate with no database +and no local configuration. """ from collections.abc import Iterator import pytest +from fastapi.testclient import TestClient from sqlalchemy import Engine, inspect from sqlalchemy.orm import Session -from server.db import get_engine, session_scope +from server.app import app +from server.db import get_engine, get_session, session_scope from server.seed import seed_reference_data -from server.settings import POOLED_URL_ENV, pooled_database_url +from server.settings import AUTH_SECRET_ENV, POOLED_URL_ENV, pooled_database_url _SKIP_REASON = ( f"{POOLED_URL_ENV} is not set — this test needs real Postgres. Local development " f"has no database by design; CI runs it against the postgres service container." ) +# Long enough to clear the 32-character floor, and constructed by repetition so it has +# almost no entropy — gitleaks scans this repo's full history and a random-looking +# string next to the word "secret" is exactly what its generic rule looks for. +_FAKE_AUTH_SECRET = "not-a-real-secret-" * 3 + + +@pytest.fixture(scope="session", autouse=True) +def _auth_secret() -> Iterator[None]: + """A deterministic signing key for the whole test session. + + Set unconditionally, overriding any real `AUTH_SECRET` from a developer's `.env`, so + a token minted in one test always verifies in another and a local run cannot behave + differently from CI. + """ + with pytest.MonkeyPatch.context() as patch: + patch.setenv(AUTH_SECRET_ENV, _FAKE_AUTH_SECRET) + yield + @pytest.fixture(scope="session") def engine() -> Engine: @@ -44,7 +68,14 @@ def engine() -> Engine: db_engine = get_engine() tables = set(inspect(db_engine).get_table_names()) - missing = {"alembic_version", "grade_system", "grade"} - tables + missing = { + "alembic_version", + "grade_system", + "grade", + "app_user", + "auth_session", + "rate_limit", + } - tables if missing: raise RuntimeError( f"database is reachable but not migrated (missing: {sorted(missing)}). " @@ -72,3 +103,29 @@ def db_session(seeded: Engine) -> Iterator[Session]: session.close() transaction.rollback() connection.close() + + +@pytest.fixture +def api_client(db_session: Session) -> Iterator[TestClient]: + """A `TestClient` whose requests run inside the test's rolled-back transaction. + + Overriding `get_session` (rather than letting the app open its own) is what keeps + endpoint tests from leaving rows behind: handlers commit freely, but a commit on a + savepoint-joined session only releases the savepoint — the outer transaction is + still rolled back in `db_session`'s teardown. + + **The base URL is https on purpose.** The refresh cookie carries `Secure`, and + httpx's cookie jar silently discards a `Secure` cookie received over http — every + refresh test would then fail for a reason that has nothing to do with the code. + """ + + def _use_test_session() -> Iterator[Session]: + # No close(): the session belongs to `db_session`, which tears it down. + yield db_session + + app.dependency_overrides[get_session] = _use_test_session + try: + with TestClient(app, base_url="https://climb.kilianmc.com") as client: + yield client + finally: + app.dependency_overrides.clear() diff --git a/tests/test_auth_demo.py b/tests/test_auth_demo.py new file mode 100644 index 0000000..39fe536 --- /dev/null +++ b/tests/test_auth_demo.py @@ -0,0 +1,123 @@ +"""`POST /api/auth/demo`, and the seed contract it depends on. + +The headline property is that this endpoint issues **zero SQL**. That is not a +micro-optimisation: Neon Free allows 400 awake-hours a month and autosuspend is fixed at +5 minutes, so a bot trickling one request a minute at a DB-touching public endpoint keeps +the compute awake permanently and exhausts the allowance on its own. The old Postgres +rate limit could not stop that, because enforcing it was itself a write. + +The first test proves zero-SQL the only way that is genuinely convincing — **with no +database configured at all** — and it runs in the local gate. The rest cover the two +things pinning `DEMO_USER_ID` could break: the id sequence, and the demo row landing +somewhere other than the pinned id. +""" + +from typing import Never + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from server.app import app +from server.auth.cookies import REFRESH_COOKIE_NAME +from server.auth.tokens import DEMO_TOKEN_TTL, decode_access_token +from server.models import AppUser +from server.seed import DEMO_USER_ID, seed_reference_data +from server.settings import POOLED_URL_ENV + +_PASSWORD = "a-long-enough-passphrase" + + +def test_demo_issues_a_token_with_no_database_configured(monkeypatch: pytest.MonkeyPatch) -> None: + """Zero SQL, proved by taking the database away entirely. + + `DATABASE_URL` is removed *and* the engine factories are booby-trapped, so any attempt + to open a session — by this handler or by a dependency it acquires — fails loudly. The + handler has no `Session` parameter, so there is nothing to acquire; a future edit that + adds one back turns this test red immediately. + + This is deliberately not a statement counter: "the endpoint cannot reach the database" + is a stronger claim than "the endpoint happened to emit no statements". + """ + + def _explode(*_args: object, **_kwargs: object) -> Never: + raise AssertionError("POST /api/auth/demo must not touch the database") + + monkeypatch.delenv(POOLED_URL_ENV, raising=False) + monkeypatch.setattr("server.db.get_engine", _explode) + monkeypatch.setattr("server.db.get_sessionmaker", _explode) + + client = TestClient(app, base_url="https://climb.kilianmc.com") + response = client.post("/api/auth/demo") + + assert response.status_code == 200, response.text + body = response.json() + assert body["scope"] == "demo" + assert body["expires_in"] == int(DEMO_TOKEN_TTL.total_seconds()) + # No refresh cookie: a demo session expires after an hour and simply ends. + assert REFRESH_COOKIE_NAME not in client.cookies + + principal = decode_access_token(body["access_token"]) + assert principal.scope == "demo" + # Straight from the pinned constant — this is what removes the lookup. + assert principal.user_id == DEMO_USER_ID + + +def test_a_demo_token_can_read_but_the_route_list_forbids_writing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Read-only, not blind. `/api/auth/me` is DB-free too, so this needs no database.""" + monkeypatch.delenv(POOLED_URL_ENV, raising=False) + client = TestClient(app, base_url="https://climb.kilianmc.com") + token = client.post("/api/auth/demo").json()["access_token"] + + me = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"}) + assert me.status_code == 200 + assert me.json() == {"user_id": DEMO_USER_ID, "scope": "demo"} + + +def test_the_seed_puts_the_demo_account_at_the_pinned_id(db_session: Session) -> None: + """Demo tokens carry `DEMO_USER_ID`, so a row anywhere else would be a silent mismatch.""" + row = db_session.scalars(select(AppUser).where(AppUser.id == DEMO_USER_ID)).one() + assert row.is_demo is True + assert row.password_hash is None, "the demo account must stay unloggable" + + +def test_the_seed_advances_the_id_sequence_past_the_demo_user(db_session: Session) -> None: + """Otherwise the first real registration collides with the demo row's primary key. + + Inserting an explicit id does not consume a sequence value, so on a fresh database + `nextval` would still return 1 — which is `DEMO_USER_ID`. `_advance_user_id_sequence` + is what prevents that, and this is the assertion that fails without it (CI's database + is fresh, so the sequence there has never been advanced by anything else). + + Consuming one value with `nextval` is safe and non-destructive; it never lowers the + sequence, so it cannot leave the database in a state that collides later. + """ + seed_reference_data(db_session) + + sequence = func.pg_get_serial_sequence(AppUser.__tablename__, "id") + next_id = db_session.scalar(select(func.nextval(sequence))) + + assert next_id is not None + assert next_id > DEMO_USER_ID, ( + f"nextval returned {next_id}, which collides with DEMO_USER_ID {DEMO_USER_ID}. " + f"The seed must repair app_user_id_seq after inserting an explicit id." + ) + + +def test_registering_after_the_seed_gets_a_fresh_id(api_client: TestClient) -> None: + """The behavioural half of the same guard, from the user's side. + + Without the sequence repair this returns **409 "already registered"** — the register + handler maps `IntegrityError` to a duplicate-email conflict, so a primary-key + collision with the demo row would present as a baffling rejection on somebody's very + first sign-up. That misdirection is the reason this test exists. + """ + response = api_client.post( + "/api/auth/register", + json={"email": "first-real-user@example.com", "password": _PASSWORD}, + ) + assert response.status_code == 201, response.text + assert decode_access_token(response.json()["access_token"]).user_id != DEMO_USER_ID diff --git a/tests/test_auth_flow.py b/tests/test_auth_flow.py new file mode 100644 index 0000000..5834c72 --- /dev/null +++ b/tests/test_auth_flow.py @@ -0,0 +1,137 @@ +"""The auth endpoints against real Postgres. + +CLAUDE.md's testing policy names auth explicitly — "register, login, refresh rotation, +logout, demo" — as a core user path that earns tests. These are integration tests on +purpose: the risky parts are the transaction boundaries, the cookie attributes and the +rate limiter's commit, none of which a unit test of the handler would exercise. + +**Skips without `DATABASE_URL`** (see `conftest.py`). CI runs them for real. +""" + +import pytest +from fastapi import Request +from fastapi.testclient import TestClient +from sqlalchemy import insert, select +from sqlalchemy.exc import DBAPIError +from sqlalchemy.orm import Session + +from server.auth.cookies import REFRESH_COOKIE_NAME +from server.auth.deps import get_request_session +from server.auth.tokens import Principal +from server.models import AppUser +from server.seed import DEMO_USER_EMAIL + +_EMAIL = "alex@example.com" +_PASSWORD = "a-long-enough-passphrase" + + +def _register(client: TestClient, email: str = _EMAIL, password: str = _PASSWORD) -> str: + response = client.post("/api/auth/register", json={"email": email, "password": password}) + assert response.status_code == 201, response.text + token: str = response.json()["access_token"] + return token + + +def test_register_login_refresh_logout_is_one_working_journey(api_client: TestClient) -> None: + access_token = _register(api_client) + assert REFRESH_COOKIE_NAME in api_client.cookies + + # The access token from registration authenticates immediately — no second round + # trip, and `/me` reads the principal straight out of the token. + me = api_client.get("/api/auth/me", headers={"Authorization": f"Bearer {access_token}"}) + assert me.status_code == 200 + assert me.json()["scope"] == "user" + + # Email is normalised on both write and lookup, so case must not matter. + login = api_client.post( + "/api/auth/login", json={"email": _EMAIL.upper(), "password": _PASSWORD} + ) + assert login.status_code == 200 + assert login.json()["scope"] == "user" + + before_refresh = api_client.cookies[REFRESH_COOKIE_NAME] + refreshed = api_client.post("/api/auth/refresh") + assert refreshed.status_code == 200 + assert api_client.cookies[REFRESH_COOKIE_NAME] != before_refresh, ( + "refresh must ROTATE the cookie; reusing the same value defeats reuse detection" + ) + + assert api_client.post("/api/auth/logout").status_code == 200 + assert REFRESH_COOKIE_NAME not in api_client.cookies + # The family is revoked, not merely forgotten by the browser. + assert api_client.post("/api/auth/refresh").status_code == 401 + + +def test_logout_without_a_cookie_succeeds(api_client: TestClient) -> None: + """Idempotent by design — a logout that can fail is a probe and a dead end for users.""" + assert api_client.post("/api/auth/logout").status_code == 200 + + +def test_registering_a_taken_email_is_a_409(api_client: TestClient) -> None: + """A deliberate, documented departure from the generic anti-enumeration answer. + + See the reasoning in `register`'s docstring: with no email-verification step, a + generic success would strand a real user. Rate limiting is the mitigation. + """ + _register(api_client) + duplicate = api_client.post("/api/auth/register", json={"email": _EMAIL, "password": _PASSWORD}) + assert duplicate.status_code == 409 + + +def test_login_gives_the_same_generic_401_for_unknown_email_and_wrong_password( + api_client: TestClient, +) -> None: + _register(api_client) + + wrong_password = api_client.post( + "/api/auth/login", json={"email": _EMAIL, "password": "not-the-password"} + ) + unknown_email = api_client.post( + "/api/auth/login", json={"email": "nobody@example.com", "password": _PASSWORD} + ) + + assert wrong_password.status_code == 401 + assert unknown_email.status_code == 401 + # Identical body: the difference between the two is exactly what an enumeration + # attack is looking for. (`verify_dummy()` equalises the timing; that part is not + # assertable here without making the suite flaky.) + assert wrong_password.json() == unknown_email.json() + + +def test_the_demo_account_can_never_be_logged_into(api_client: TestClient) -> None: + """Its `password_hash` is NULL, so there is no password that could match.""" + response = api_client.post( + "/api/auth/login", json={"email": DEMO_USER_EMAIL, "password": _PASSWORD} + ) + assert response.status_code == 401 + + +def test_a_demo_principal_cannot_write_at_the_database_level(db_session: Session) -> None: + """The SECOND line of defence, independent of the 403 in `enforce_auth`. + + `SET LOCAL transaction_read_only` means the database itself refuses, so a future + handler that somehow bypasses the middleware still cannot mutate anything. This test + exercises the dependency rather than raw SQL — the thing that could regress is the + wiring, not Postgres. + """ + scope = {"type": "http", "method": "POST", "path": "/api/anything", "headers": []} + request = Request(scope) + request.state.principal = Principal(user_id=1, scope="demo") + + session = next(get_request_session(request, db_session)) + try: + with pytest.raises(DBAPIError): + session.execute( + insert(AppUser).values(email="should-never-exist@example.com", is_demo=False) + ) + finally: + # A failed statement poisons the SAVEPOINT, and the rollback is also what clears + # the read-only flag for the rest of the fixture's teardown. + db_session.rollback() + + assert ( + db_session.scalar( + select(AppUser.id).where(AppUser.email == "should-never-exist@example.com") + ) + is None + ) diff --git a/tests/test_auth_ratelimit.py b/tests/test_auth_ratelimit.py new file mode 100644 index 0000000..faaee7c --- /dev/null +++ b/tests/test_auth_ratelimit.py @@ -0,0 +1,171 @@ +"""Login's two rate-limit dimensions. + +The per-IP bucket alone is a weak control: an attacker with a hundred addresses gets a +hundred fresh budgets. `LOGIN_ACCOUNT` keys on the attempted email instead, so the limit +binds to the *target* of the attack. These tests cover the three properties that make +that worth having and safe to have — it trips independently of the IP bucket, it is not +an account-existence oracle, and it costs no extra database round trip. + +Per CLAUDE.md's testing policy these sit under "core user paths — auth", and the +single-statement test guards a design decision that is invisible from behaviour and +would otherwise rot silently. + +**Skips without `DATABASE_URL`** (see `conftest.py`). CI runs them for real. +""" + +from datetime import UTC, datetime +from typing import Any + +from fastapi.testclient import TestClient +from sqlalchemy import event, insert +from sqlalchemy.orm import Session + +from server.auth import ratelimit +from server.models import RateLimit + +_PASSWORD = "a-long-enough-passphrase" +_KNOWN = "resident@example.com" +_UNKNOWN = "ghost@example.com" + + +def _login(client: TestClient, email: str, ip: str) -> Any: + """One login attempt from a stated client address. + + `x-forwarded-for` is what `ratelimit.client_ip` reads. On Vercel the platform sets + that header and a client cannot; here it is the only way to simulate distinct + sources, and it is exactly why the local limiter is bypassable in development. + """ + return client.post( + "/api/auth/login", + json={"email": email, "password": _PASSWORD}, + headers={"x-forwarded-for": ip}, + ) + + +def _preload_bucket(session: Session, rule: ratelimit.Rule, subject: str, count: int) -> None: + """Put a bucket one request away from its limit, without making that many requests. + + Uses the module's own key and window helpers, so a change to either is caught here + rather than producing a test that silently stops exercising the limit. + """ + session.execute( + insert(RateLimit).values( + bucket=ratelimit.bucket_key(rule, subject), + window_start=ratelimit.window_start_for(rule, datetime.now(UTC)), + count=count, + ) + ) + session.commit() + + +def test_the_per_ip_bucket_trips_at_its_threshold(api_client: TestClient) -> None: + """The counter has to survive the endpoint's own transaction, or it never trips. + + `REGISTER` (3/hour) is the bucket used here because it is the tightest IP-keyed rule + and each attempt is a real expense — an argon2 hash plus a row. Requests 1-3 create + accounts; the fourth is refused before any hashing happens. + """ + for attempt in range(ratelimit.REGISTER.limit): + response = api_client.post( + "/api/auth/register", + json={"email": f"newcomer{attempt}@example.com", "password": _PASSWORD}, + ) + assert response.status_code == 201, f"attempt {attempt + 1}: {response.text}" + + blocked = api_client.post( + "/api/auth/register", json={"email": "one-too-many@example.com", "password": _PASSWORD} + ) + assert blocked.status_code == 429 + assert int(blocked.headers["Retry-After"]) > 0 + + +def test_the_per_email_bucket_trips_even_though_every_request_has_a_different_ip( + api_client: TestClient, +) -> None: + """The whole point of the account-keyed rule: rotating source addresses does not help. + + Each attempt comes from a unique address, so the per-IP `LOGIN` bucket (10 per 15 min) + never reaches its limit — every one of these sits at a count of 1. Only the + account-keyed bucket accumulates. + """ + for attempt in range(ratelimit.LOGIN_ACCOUNT.limit): + response = _login(api_client, _UNKNOWN, f"203.0.113.{attempt}") + assert response.status_code == 401, f"attempt {attempt + 1}: {response.text}" + + blocked = _login(api_client, _UNKNOWN, "198.51.100.7") + assert blocked.status_code == 429 + assert int(blocked.headers["Retry-After"]) > 0 + + +def test_the_429_is_identical_for_an_existing_and_a_nonexistent_address( + api_client: TestClient, db_session: Session +) -> None: + """The account-keyed limit must not become an account-existence oracle. + + The email counter increments for **any** address attempted, so an address that has + never been registered exhausts its bucket exactly like a real one — and the rejection + is byte-identical. If `enforce_all` ever skipped the count for unknown addresses, or + named the bucket that tripped, this would fail. + """ + registered = api_client.post( + "/api/auth/register", json={"email": _KNOWN, "password": _PASSWORD} + ) + assert registered.status_code == 201 + + for email in (_KNOWN, _UNKNOWN): + _preload_bucket(db_session, ratelimit.LOGIN_ACCOUNT, email, ratelimit.LOGIN_ACCOUNT.limit) + + known = _login(api_client, _KNOWN, "203.0.113.10") + unknown = _login(api_client, _UNKNOWN, "203.0.113.11") + + assert known.status_code == 429 + assert unknown.status_code == 429 + assert known.json() == unknown.json() + # Header *names* rather than values: Retry-After is a countdown and the two requests + # are a few milliseconds apart, so the values may legitimately differ by a second. + assert sorted(known.headers) == sorted(unknown.headers) + assert "retry-after" in known.headers + + +def test_login_counts_both_buckets_in_a_single_statement( + api_client: TestClient, db_session: Session +) -> None: + """Two dimensions for the cost of one round trip — the reason `enforce_all` exists. + + Not a restatement of the implementation: the whole justification for adding an + account-keyed limit was that it costs no extra latency and no extra Neon wake-up. If + someone later "simplifies" it into two `enforce()` calls that property is gone, and + nothing about the observable behaviour would change. + """ + captured: list[tuple[str, Any]] = [] + + def _record( + conn: Any, + cursor: Any, + statement: str, + parameters: Any, + context: Any, + executemany: bool, + ) -> None: + captured.append((statement, parameters)) + + bind = db_session.get_bind() + event.listen(bind, "before_cursor_execute", _record) + try: + assert _login(api_client, _UNKNOWN, "203.0.113.20").status_code == 401 + finally: + event.remove(bind, "before_cursor_execute", _record) + + writes = [ + (statement, parameters) + for statement, parameters in captured + if "rate_limit" in statement and statement.lstrip().upper().startswith("INSERT") + ] + assert len(writes) == 1, ( + f"login must count all of its buckets in ONE statement, saw {len(writes)}: " + f"{[statement for statement, _ in writes]}" + ) + + _, parameters = writes[0] + buckets = {value for key, value in dict(parameters).items() if "bucket" in key} + assert len(buckets) == 2, f"expected the IP and account buckets in one statement: {buckets}" diff --git a/tests/test_auth_refresh.py b/tests/test_auth_refresh.py new file mode 100644 index 0000000..ef7d1b5 --- /dev/null +++ b/tests/test_auth_refresh.py @@ -0,0 +1,211 @@ +"""Refresh rotation and reuse detection. + +The single most important behaviour in `server/auth/refresh.py`, and the reason the +`auth_session` table stores a chain rather than one row per session: a replayed token +must take the **whole family** down, including the successor the legitimate client is +holding. Getting this subtly wrong — issuing a fresh token on a replay, or revoking only +the presented row — leaves a thief with an indefinitely renewable session, and nothing +else in the suite would notice. + +**Skips without `DATABASE_URL`** (see `conftest.py`). CI runs them for real. +""" + +import threading + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import Engine, delete, select +from sqlalchemy.orm import Session + +from server.auth import refresh +from server.auth.cookies import REFRESH_COOKIE_NAME +from server.db import get_sessionmaker, session_scope +from server.models import AppUser, AuthSession + +_HOST = "climb.kilianmc.com" +_COOKIE_PATH = "/api/auth" + + +def _a_user(session: Session, email: str = "rotator@example.com") -> int: + # `password_hash` is irrelevant here — nothing in this module verifies a password. + user = AppUser(email=email, password_hash="unused-in-this-test", is_demo=False) + session.add(user) + session.flush() + return user.id + + +def _family(session: Session, family_id: object) -> list[AuthSession]: + return list( + session.scalars(select(AuthSession).where(AuthSession.family_id == family_id)).all() + ) + + +def test_rotation_replaces_the_token_and_keeps_the_family(db_session: Session) -> None: + user_id = _a_user(db_session) + first = refresh.issue(db_session, user_id) + + second = refresh.rotate(db_session, first.token) + + assert second.token != first.token + assert second.family_id == first.family_id, "a rotation must stay in the same chain" + assert second.user_id == user_id + + retired = db_session.scalars( + select(AuthSession).where(AuthSession.token_hash == refresh.digest(first.token)) + ).one() + assert retired.rotated_at is not None + assert retired.revoked_at is None + + +def test_replaying_a_rotated_token_revokes_the_entire_family(db_session: Session) -> None: + """Reuse detection. The successor must die with the replayed token, not survive it.""" + user_id = _a_user(db_session) + first = refresh.issue(db_session, user_id) + second = refresh.rotate(db_session, first.token) + + with pytest.raises(refresh.RefreshRejectedError): + refresh.rotate(db_session, first.token) + + rows = _family(db_session, first.family_id) + assert len(rows) == 2 + assert all(row.revoked_at is not None for row in rows), ( + "reuse must revoke every token in the family, not just the one presented" + ) + + # The token the legitimate client is holding is now dead too. That is the intended + # trade: we cannot tell the victim from the thief, so both are logged out. + with pytest.raises(refresh.RefreshRejectedError): + refresh.rotate(db_session, second.token) + + +def test_an_unknown_token_is_rejected_without_touching_any_family(db_session: Session) -> None: + user_id = _a_user(db_session) + live = refresh.issue(db_session, user_id) + + with pytest.raises(refresh.RefreshRejectedError): + refresh.rotate(db_session, "not-a-token-anyone-ever-issued") + + assert _family(db_session, live.family_id)[0].revoked_at is None + + +def test_logout_revokes_the_family_and_is_idempotent(db_session: Session) -> None: + user_id = _a_user(db_session) + issued = refresh.issue(db_session, user_id) + + assert refresh.revoke_presented(db_session, issued.token) is True + assert _family(db_session, issued.family_id)[0].revoked_at is not None + + # Logging out twice, or with a token that was never real, is a no-op — not an error + # and not a way to learn whether a token exists. + assert refresh.revoke_presented(db_session, issued.token) is True + assert refresh.revoke_presented(db_session, "never-issued") is False + + +def test_reuse_through_the_api_kills_the_session_the_client_is_holding( + api_client: TestClient, +) -> None: + """End to end: capture a cookie, let the real client rotate, then replay.""" + registered = api_client.post( + "/api/auth/register", + json={"email": "victim@example.com", "password": "a-long-enough-passphrase"}, + ) + assert registered.status_code == 201 + captured = api_client.cookies[REFRESH_COOKIE_NAME] + + assert api_client.post("/api/auth/refresh").status_code == 200 + live = api_client.cookies[REFRESH_COOKIE_NAME] + assert live != captured + + # The attacker replays the cookie they captured before the rotation. + api_client.cookies.set(REFRESH_COOKIE_NAME, captured, domain=_HOST, path=_COOKIE_PATH) + assert api_client.post("/api/auth/refresh").status_code == 401 + + # And the victim's current, never-leaked token is now dead as well. + api_client.cookies.set(REFRESH_COOKIE_NAME, live, domain=_HOST, path=_COOKIE_PATH) + assert api_client.post("/api/auth/refresh").status_code == 401 + + +_RACE_EMAIL = "race@example.com" + + +def _purge_race_user() -> None: + """Committed rows, so they need explicit cleanup — the savepoint fixture is not used.""" + with session_scope() as cleanup: + cleanup.execute(delete(AppUser).where(AppUser.email == _RACE_EMAIL)) + + +def test_two_simultaneous_rotations_of_one_token_cannot_both_succeed(seeded: Engine) -> None: + """Regression for the lost-update race that silently bypassed reuse detection. + + Two requests presenting the SAME refresh token at the same time used to both read + `rotated_at IS NULL`, both pass the reuse check and both mint a successor — leaving + two live tokens in one family and no detection at all. `rotate()` now reads its row + `FOR UPDATE`, which serialises them. + + This test does NOT use `db_session`: proving a row lock needs two real transactions + on two real connections, so the rows are committed and cleaned up by hand. + + **What makes it a genuine proof:** with the lock, the second transaction re-reads + after the first commits, sees `rotated_at` and is rejected. Without it, the second + decides from its stale snapshot and returns a successor — `outcome` is `"rotated"` + and the test fails. (The `is_alive` check below is only a guard against the + degenerate ordering where the second finishes before the first commits; on its own + it proves nothing, because an unlocked read still blocks later on the UPDATE.) + """ + _purge_race_user() + maker = get_sessionmaker() + first = maker() + second = maker() + outcome: dict[str, str] = {} + started = threading.Event() + + try: + with session_scope() as setup: + user = AppUser(email=_RACE_EMAIL, password_hash="unused", is_demo=False) + setup.add(user) + setup.flush() + issued = refresh.issue(setup, user.id) + + # Transaction A rotates but does not commit, so it still holds the row lock. + refresh.rotate(first, issued.token) + + def _competing_rotation() -> None: + started.set() + try: + refresh.rotate(second, issued.token) + outcome["result"] = "rotated" + except refresh.RefreshRejectedError: + outcome["result"] = "rejected" + except Exception as exc: + outcome["result"] = f"error: {exc!r}" + finally: + try: + second.commit() + except Exception: + second.rollback() + + thread = threading.Thread(target=_competing_rotation, daemon=True) + thread.start() + assert started.wait(timeout=5), "the competing thread never started" + thread.join(timeout=1.0) + assert thread.is_alive(), "the competing rotation did not wait for the first one" + + first.commit() + thread.join(timeout=15) + assert not thread.is_alive(), "the competing rotation never unblocked" + + assert outcome["result"] == "rejected", ( + f"a concurrent replay was not detected (outcome: {outcome.get('result')!r}). " + f"This is what happens when the FOR UPDATE in refresh.rotate() is removed." + ) + + with session_scope() as check: + rows = list( + check.scalars(select(AuthSession).where(AuthSession.family_id == issued.family_id)) + ) + assert len(rows) == 2, "the loser must not have minted a third token" + assert all(row.revoked_at is not None for row in rows), "the family must be revoked" + finally: + first.close() + second.close() + _purge_race_user() diff --git a/tests/test_auth_routes_enumerated.py b/tests/test_auth_routes_enumerated.py new file mode 100644 index 0000000..c3ecd35 --- /dev/null +++ b/tests/test_auth_routes_enumerated.py @@ -0,0 +1,138 @@ +"""Deny-by-default, proved by walking the route table rather than trusting a review. + +CLAUDE.md: *"Authentication is required unless a route appears on an explicitly +enumerated public-route list. A test walks every registered route and asserts each one +is either on that list or protected."* This is that test, plus its demo-mode twin, which +is the named acceptance criterion for PR #3. + +The value of these is entirely in what they catch **later**: an endpoint added in PR #9 +that nobody remembered to protect, or a mutating route that a demo token can reach. Both +would pass every test written about the feature itself. Per the testing policy in +CLAUDE.md, this is the "project-wide invariant that silently rots" bullet. + +**No database, on purpose — and enforced, not merely hoped for.** Every assertion here +is about a rejection that happens in a dependency, before any handler runs, so this file +must run in the local gate whether or not `DATABASE_URL` is set. The autouse fixture +below replaces `get_session` with one that raises, which does two things: it keeps a +developer's real Neon database out of a test that walks *every* endpoint firing +requests at it, and it makes "this route reached its handler" show up as a 500 rather +than as a quiet, successful write. Server exceptions are converted to 500 responses so +one such route cannot abort the walk before it reaches the rest. +""" + +import re +from collections.abc import Iterator + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from server.app import app +from server.auth.deps import DEMO_WRITE_EXEMPT_ROUTES, MUTATING_METHODS, PUBLIC_ROUTES +from server.auth.tokens import issue_access_token +from server.db import get_session + +_PATH_PARAM = re.compile(r"\{[^}]+\}") + +client = TestClient( + app, + base_url="https://climb.kilianmc.com", + raise_server_exceptions=False, +) + + +@pytest.fixture(autouse=True) +def _no_database() -> Iterator[None]: + def _refuse() -> Iterator[Session]: + raise AssertionError( + "a route in the enumeration walk reached its handler and asked for a " + "database session; these tests must be decided by dependencies alone" + ) + + app.dependency_overrides[get_session] = _refuse + try: + yield + finally: + app.dependency_overrides.clear() + + +def _registered_routes() -> list[tuple[str, str]]: + """Every `(method, path)` the application actually serves. + + HEAD and OPTIONS are dropped: Starlette synthesises HEAD from GET and the CORS + middleware owns OPTIONS, so neither is a route anyone declared. + """ + routes: list[tuple[str, str]] = [] + for route in app.routes: + path = getattr(route, "path", None) + methods = getattr(route, "methods", None) + if not isinstance(path, str) or not methods: + continue + routes.extend( + (method, path) for method in sorted(methods) if method not in {"HEAD", "OPTIONS"} + ) + return routes + + +def _requestable(path: str) -> str: + """Fill in path parameters so the request reaches the route it is aimed at.""" + return _PATH_PARAM.sub("1", path) + + +def test_every_route_is_public_by_declaration_or_rejects_anonymous_requests() -> None: + """A route that is neither listed nor protected is the bug this test exists to find.""" + offenders: list[str] = [] + for method, path in _registered_routes(): + if (method, path) in PUBLIC_ROUTES: + continue + status_code = client.request(method, _requestable(path)).status_code + if status_code != 401: + offenders.append(f" {method} {path} -> {status_code}") + assert not offenders, ( + "these routes answered an UNAUTHENTICATED request with something other than 401 " + "and are not in PUBLIC_ROUTES:\n" + + "\n".join(offenders) + + "\n\nEither protect the route, or add it to PUBLIC_ROUTES in " + "server/auth/deps.py with a comment saying why it is public." + ) + + +def test_public_route_list_has_no_entries_for_routes_that_do_not_exist() -> None: + """A stale or typo'd entry silently protects the route it was meant to open. + + The failure is confusing when it happens (the endpoint 401s and the list "clearly" + says it is public), so catch it here where the message can say so. + """ + stale = sorted(PUBLIC_ROUTES - set(_registered_routes())) + assert not stale, ( + f"PUBLIC_ROUTES names routes that are not registered: {stale}. " + f"A typo here does not fail open — it leaves the real route protected." + ) + + +def test_every_mutating_route_forbids_a_demo_token() -> None: + """The PR #3 acceptance criterion: demo mode cannot write, anywhere. + + `POST /api/auth/demo` is the single enumerated exception — it is the endpoint that + issues the token, so a client that already has one must still be able to call it. + """ + # Any user id will do: the ban is decided from the token's scope, and nothing gets + # far enough to look the account up. Minted inside the test because the AUTH_SECRET + # fixture has not run at collection time. + headers = {"Authorization": f"Bearer {issue_access_token(1, 'demo').token}"} + offenders: list[str] = [] + for method, path in _registered_routes(): + if method not in MUTATING_METHODS: + continue + status_code = client.request(method, _requestable(path), headers=headers).status_code + if (method, path) in DEMO_WRITE_EXEMPT_ROUTES: + assert status_code != 403, f"{method} {path} is exempt but was still forbidden" + continue + if status_code != 403: + offenders.append(f" {method} {path} -> {status_code}") + assert not offenders, ( + "a demo-scope token was NOT rejected with 403 on these mutating routes:\n" + + "\n".join(offenders) + + "\n\nDemo mode is read-only (CLAUDE.md). If a route genuinely must accept a " + "demo write, add it to DEMO_WRITE_EXEMPT_ROUTES and justify it in the diff." + ) diff --git a/tests/test_auth_tokens.py b/tests/test_auth_tokens.py new file mode 100644 index 0000000..a878def --- /dev/null +++ b/tests/test_auth_tokens.py @@ -0,0 +1,118 @@ +"""Access-token verification — the rejections, not the happy path. + +Every test here forges a token that a naive `jwt.decode(token, key)` would accept. That +is the point: JWT's failure modes are all *acceptance* failures, and each one below is a +documented attack rather than a hypothetical. + +Pure — no database — so the local gate runs them. `AUTH_SECRET` comes from the autouse +fixture in `conftest.py`. +""" + +from datetime import UTC, datetime, timedelta + +import jwt +import pytest + +from server.auth.tokens import ( + ALGORITHM, + ISSUER, + TOKEN_TYPE, + USER_TOKEN_TTL, + InvalidAccessTokenError, + decode_access_token, + issue_access_token, +) +from server.settings import auth_secret + + +def _claims(**overrides: object) -> dict[str, object]: + now = datetime.now(UTC) + claims: dict[str, object] = { + "sub": "42", + "scope": "user", + "typ": TOKEN_TYPE, + "iss": ISSUER, + "iat": now, + "exp": now + USER_TOKEN_TTL, + } + claims.update(overrides) + return claims + + +def test_a_freshly_issued_token_round_trips_to_its_principal() -> None: + issued = issue_access_token(42, "user") + principal = decode_access_token(issued.token) + assert (principal.user_id, principal.scope) == (42, "user") + assert issued.expires_in == int(USER_TOKEN_TTL.total_seconds()) + + +def test_an_unsigned_token_is_rejected() -> None: + """`alg: none`. The classic forgery: strip the signature, claim it was never needed. + + `algorithms=["HS256"]` in `decode_access_token` is what stops it — without that + argument PyJWT would consider the algorithm the *token* names. + """ + forged = jwt.encode(_claims(), key="", algorithm="none") + with pytest.raises(InvalidAccessTokenError): + decode_access_token(forged) + + +# PyJWT warns that the key is short for SHA-512. That is the forgery's problem, not +# ours — the point of the test is that the pinned algorithm list refuses the token. +@pytest.mark.filterwarnings("ignore:The HMAC key is") +def test_a_token_signed_with_a_different_hmac_algorithm_is_rejected() -> None: + """Algorithm confusion: same secret, different `alg`, so the signature verifies + under a permissive verifier and the pinned list is the only thing refusing it.""" + forged = jwt.encode(_claims(), auth_secret(), algorithm="HS512") + with pytest.raises(InvalidAccessTokenError): + decode_access_token(forged) + + +def test_a_token_signed_with_the_wrong_secret_is_rejected() -> None: + forged = jwt.encode(_claims(), "a-different-key-of-sufficient-length", algorithm=ALGORITHM) + with pytest.raises(InvalidAccessTokenError): + decode_access_token(forged) + + +def test_a_tampered_signature_is_rejected() -> None: + """Flip one character of the signature; everything else is byte-identical.""" + header, payload, signature = issue_access_token(42, "user").token.split(".") + swapped = ("B" if signature[0] != "B" else "C") + signature[1:] + with pytest.raises(InvalidAccessTokenError): + decode_access_token(f"{header}.{payload}.{swapped}") + + +def test_an_expired_token_is_rejected() -> None: + past = datetime.now(UTC) - timedelta(minutes=1) + expired = jwt.encode(_claims(iat=past - timedelta(hours=3), exp=past), auth_secret(), ALGORITHM) + with pytest.raises(InvalidAccessTokenError): + decode_access_token(expired) + + +def test_a_token_with_no_expiry_is_rejected_rather_than_treated_as_eternal() -> None: + """`require=[...]` in the decoder. An absent claim must never mean "no limit".""" + claims = _claims() + del claims["exp"] + with pytest.raises(InvalidAccessTokenError): + decode_access_token(jwt.encode(claims, auth_secret(), ALGORITHM)) + + +def test_a_token_of_another_type_is_rejected() -> None: + """The confused-deputy guard: a future refresh/verification JWT must not be usable + as an access token just because it is correctly signed by the same key.""" + forged = jwt.encode(_claims(typ="refresh"), auth_secret(), ALGORITHM) + with pytest.raises(InvalidAccessTokenError): + decode_access_token(forged) + + +def test_a_token_from_another_issuer_is_rejected() -> None: + forged = jwt.encode(_claims(iss="portfolio-shell"), auth_secret(), ALGORITHM) + with pytest.raises(InvalidAccessTokenError): + decode_access_token(forged) + + +def test_an_unknown_scope_is_rejected() -> None: + """Scope is a closed vocabulary; `admin` must not become a valid one by assertion.""" + forged = jwt.encode(_claims(scope="admin"), auth_secret(), ALGORITHM) + with pytest.raises(InvalidAccessTokenError): + decode_access_token(forged) diff --git a/uv.lock b/uv.lock index 10e0972..b25b3f6 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,8 @@ revision = 3 requires-python = ">=3.13" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version < '3.15'", + "python_full_version == '3.14.*'", + "python_full_version < '3.14'", ] [[package]] @@ -50,6 +51,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + [[package]] name = "ast-serialize" version = "0.8.0" @@ -123,6 +167,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "click" version = "8.4.2" @@ -141,8 +258,11 @@ version = "0.0.0" source = { virtual = "." } dependencies = [ { name = "alembic" }, + { name = "argon2-cffi" }, + { name = "email-validator" }, { name = "fastapi" }, { name = "psycopg", extra = ["binary"] }, + { name = "pyjwt" }, { name = "python-dotenv" }, { name = "sqlalchemy" }, ] @@ -159,8 +279,11 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = "==1.19.1" }, + { name = "argon2-cffi", specifier = "==25.1.0" }, + { name = "email-validator", specifier = "==2.3.0" }, { name = "fastapi", specifier = "==0.128.8" }, { name = "psycopg", extras = ["binary"], specifier = "==3.3.4" }, + { name = "pyjwt", specifier = "==2.13.0" }, { name = "python-dotenv", specifier = "==1.2.2" }, { name = "sqlalchemy", specifier = "==2.0.52" }, ] @@ -183,6 +306,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "fastapi" version = "0.128.8" @@ -571,6 +716,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -651,6 +805,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + [[package]] name = "pytest" version = "9.0.1"