**The API.** Every route now lives under `/api/v1`, with `/api/...` rewritten
onto it — one route, two spellings, so they cannot drift and the OpenAPI
document describes each endpoint once. Errors carry an `error` object with a
stable code, one human sentence and, for a validation failure, the fields that
were wrong; `detail` is untouched so nothing that reads it breaks. The whole
surface — 320 routes, their parameters and their status codes — is checked in
as `backend/tests/api-contract.json`, and a test fails on any difference,
naming the routes that moved. `docs/api.md` is the contract in prose.
**Refresh tokens**, so an app can stay signed in without keeping a password.
Rows rather than signatures: listable, withdrawable, stored as hashes, rotated
on every use. A spent token coming back ends the whole session, because a theft
and a replay look identical from the server and the safe reading is the unsafe
one. A browser is not given one — it has nowhere to put it and a person to ask.
**An end-to-end stack**: `docker-compose.test.yml` with its own Postgres and
Redis, `e2e/seed.py` for the smallest world the tests name, and Playwright with
five projects — desktop, iPhone, Pixel, iPad and a browserless API project.
Devices because every bug reported this week was a phone bug found by a person
looking at a screenshot; a desktop-only suite would have passed through all of
them. Forty tests, five clean runs.
It found four things in its first hour:
- **A fresh deploy could not start.** `create_all()` ran before
`CREATE EXTENSION vector`, so any database that had never had pgvector
installed died on the first table with a vector column. Invisible here
because this one has had the extension for a year.
- **A figure in a published article was a 404 for everyone but an admin.**
Media in the library is nobody's to read by default, and nothing made an
exception for a drawing an article actually shows — so every illustration
added this week was an empty box for every real user.
- **Every rate limit was one bucket for the whole site.** The backend saw
nginx's address for every request, so ten bad passwords from anybody locked
out everybody, and no log line could say who. nginx now takes the real
address from the proxy and overwrites the header on the way in; uvicorn runs
with --proxy-headers.
- **The reading page's breakpoints disagreed** — 1150px in the component,
820px in the stylesheet. Between them the menu button claimed the contents
drawer and then toggled a class on a rail that was still in the layout: the
contents did not open and the site menu did not either. The button was dead
on every tablet.
And two smaller ones: the login limiter counted successful sign-ins, so eleven
people behind one hospital NAT locked each other out — it is cleared by a
correct password now; and `/uploads/{path}` served GET and HEAD from one route
with one operation id, which makes every OpenAPI client generator refuse the
document.
The first admin's password is generated and printed once at first start when
`DEFAULT_ADMIN_PASSWORD` is blank, rather than the account not existing:
`docker compose logs backend | grep -A3 "FIRST ADMIN"`.
CI (`.forgejo/workflows/tests.yml`) runs the backend suite, the contract, the
frontend suite and the build on every push to dev, main or master, and the
end-to-end stack on those branches and on pull requests into them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
164 lines
8 KiB
Python
164 lines
8 KiB
Python
"""Staying signed in without keeping a password.
|
|
|
|
A browser can send a person back to a login form; an app on a phone cannot, and
|
|
must not keep the password to avoid it. So sign-in can hand back a refresh
|
|
token: a row rather than a signature, which means it can be listed, rotated and
|
|
withdrawn — none of which is true of the access token beside it.
|
|
|
|
The properties worth pinning are the ones that only show up when they fail: a
|
|
token works exactly once, a spent one presented again ends the whole session
|
|
rather than being politely ignored, and signing out of a lost phone stops it
|
|
getting another token even though the one it holds is still cryptographically
|
|
valid.
|
|
|
|
Disposable SQLite, a Mock for Redis, no network.
|
|
"""
|
|
import sys
|
|
import unittest
|
|
from datetime import datetime, timedelta
|
|
from types import ModuleType
|
|
from unittest.mock import patch
|
|
|
|
import test_quiz_builder as fixtures
|
|
from app.models.refresh_token import RefreshToken
|
|
from app.routers import auth as auth_router
|
|
from app.services import refresh_tokens
|
|
from app.utils.auth import get_current_user
|
|
|
|
|
|
class MemoryRedis:
|
|
def __init__(self): self.values = {}
|
|
def get(self, key): return self.values.get(key)
|
|
def set(self, key, value, **kwargs): self.values[key] = value
|
|
def setex(self, key, ttl, value): self.values[key] = value
|
|
def incr(self, key):
|
|
self.values[key] = int(self.values.get(key, 0)) + 1
|
|
return self.values[key]
|
|
def ttl(self, key): return 60
|
|
def expire(self, *args): return True
|
|
def delete(self, *args): return True
|
|
|
|
|
|
class RefreshTokenTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.bank = fixtures.BuilderTests()
|
|
self.bank.setUp()
|
|
self.bank.owner.email = "owner@example.com"
|
|
self.bank.db.commit()
|
|
self.client = self.bank.client
|
|
self.client.app.include_router(auth_router.router, prefix="/auth")
|
|
# The fixture pins every request to one user; these tests are about who
|
|
# the token says you are, so the real dependency goes back in.
|
|
self.client.app.dependency_overrides.pop(get_current_user, None)
|
|
self.client.app.dependency_overrides[get_current_user] = lambda: self.bank.user
|
|
|
|
redis = ModuleType("redis")
|
|
redis.from_url = lambda *a, **k: MemoryRedis()
|
|
modules = patch.dict(sys.modules, {"redis": redis})
|
|
modules.start()
|
|
self.addCleanup(modules.stop)
|
|
|
|
def tearDown(self):
|
|
self.bank.tearDown()
|
|
|
|
def sign_in(self, **extra):
|
|
return refresh_tokens.issue(self.bank.db, self.bank.owner, **extra)
|
|
|
|
def refresh(self, token):
|
|
return self.client.post("/auth/refresh", json={"refresh_token": token})
|
|
|
|
# ── Issuing ────────────────────────────────────────────────────
|
|
def test_a_browser_is_not_given_something_it_will_never_use(self):
|
|
# Sign-in only mints a refresh token for a client that says it wants
|
|
# one. Handing every browser a sixty-day credential it has nowhere to
|
|
# put is a row in a table and a liability, for nothing.
|
|
self.assertEqual(self.bank.db.query(RefreshToken).count(), 0)
|
|
|
|
def test_only_the_hash_is_kept(self):
|
|
token = self.sign_in()
|
|
rows = self.bank.db.query(RefreshToken).all()
|
|
self.assertEqual(len(rows), 1)
|
|
self.assertNotIn(token, [row.token_hash for row in rows])
|
|
self.assertEqual(len(rows[0].token_hash), 64)
|
|
|
|
# ── Spending ───────────────────────────────────────────────────
|
|
def test_a_token_is_traded_for_a_new_pair_and_cannot_be_used_twice(self):
|
|
token = self.sign_in()
|
|
first = self.refresh(token)
|
|
self.assertEqual(first.status_code, 200, first.text)
|
|
rotated = first.json()["refresh_token"]
|
|
self.assertTrue(first.json()["access_token"])
|
|
self.assertNotEqual(rotated, token)
|
|
self.assertEqual(self.refresh(rotated).status_code, 200)
|
|
|
|
def test_a_spent_token_coming_back_ends_the_whole_session(self):
|
|
# Either it was copied or a client is replaying; from here those look
|
|
# the same, so the safe reading is the unsafe one. The thief and the
|
|
# owner both stop working, and the owner signs in again knowing why.
|
|
token = self.sign_in()
|
|
rotated = self.refresh(token).json()["refresh_token"]
|
|
self.assertEqual(self.refresh(token).status_code, 401)
|
|
self.assertEqual(self.refresh(rotated).status_code, 401)
|
|
self.assertEqual(refresh_tokens.sessions(self.bank.db, self.bank.owner), [])
|
|
|
|
def test_an_expired_or_unknown_token_is_refused_without_a_hint(self):
|
|
self.assertEqual(self.refresh("never-issued").status_code, 401)
|
|
token = self.sign_in()
|
|
row = self.bank.db.query(RefreshToken).one()
|
|
row.expires_at = datetime.utcnow() - timedelta(seconds=1)
|
|
self.bank.db.commit()
|
|
self.assertEqual(self.refresh(token).status_code, 401)
|
|
|
|
# ── Withdrawing ────────────────────────────────────────────────
|
|
def test_signing_out_stops_the_lost_phone_getting_another_token(self):
|
|
phone = self.sign_in(label="iPhone")
|
|
laptop = self.sign_in(label="Laptop")
|
|
self.assertEqual(len(refresh_tokens.sessions(self.bank.db, self.bank.owner)), 2)
|
|
|
|
self.assertEqual(self.client.post("/auth/logout", json={"refresh_token": phone}).status_code, 204)
|
|
self.assertEqual(self.refresh(phone).status_code, 401)
|
|
self.assertEqual(self.refresh(laptop).status_code, 200)
|
|
|
|
def test_signing_out_everywhere_ends_every_session(self):
|
|
tokens = [self.sign_in(label=f"Device {i}") for i in range(3)]
|
|
self.assertEqual(self.client.post("/auth/logout", json={"everywhere": True}).status_code, 204)
|
|
for token in tokens:
|
|
self.assertEqual(self.refresh(token).status_code, 401)
|
|
|
|
def test_a_person_can_see_where_they_are_signed_in_and_end_one(self):
|
|
self.sign_in(label="iPhone")
|
|
self.sign_in(label="Laptop")
|
|
rows = self.client.get("/auth/sessions").json()
|
|
self.assertEqual({row["label"] for row in rows}, {"iPhone", "Laptop"})
|
|
# One row per session, not one per rotation: a token refreshed nightly
|
|
# for two months is one place you are signed in, not sixty.
|
|
phone = next(row for row in rows if row["label"] == "iPhone")
|
|
self.refresh(self.sign_in(label="iPhone", family=phone["family"]))
|
|
self.assertEqual(len(self.client.get("/auth/sessions").json()), 2)
|
|
|
|
self.assertEqual(self.client.delete(f"/auth/sessions/{phone['family']}").status_code, 204)
|
|
self.assertEqual([row["label"] for row in self.client.get("/auth/sessions").json()], ["Laptop"])
|
|
|
|
def test_one_person_cannot_end_another_person_s_session(self):
|
|
theirs = refresh_tokens.issue(self.bank.db, self.bank.peer, label="Their laptop")
|
|
rows = refresh_tokens.sessions(self.bank.db, self.bank.peer)
|
|
self.bank.user = self.bank.owner
|
|
self.assertEqual(self.client.delete(f"/auth/sessions/{rows[0]['family']}").status_code, 204)
|
|
# Answered 204 because there was nothing of theirs by that name to end;
|
|
# what matters is that the other person is still signed in.
|
|
self.assertEqual(self.refresh(theirs).status_code, 200)
|
|
|
|
# ── Housekeeping ───────────────────────────────────────────────
|
|
def test_dead_rows_are_swept_and_live_ones_are_not(self):
|
|
live = self.sign_in(label="Live")
|
|
old = self.sign_in(label="Old")
|
|
row = self.bank.db.query(RefreshToken).filter(RefreshToken.label == "Old").one()
|
|
row.revoked_at = datetime.utcnow() - timedelta(days=30)
|
|
self.bank.db.commit()
|
|
self.assertEqual(refresh_tokens.purge(self.bank.db), 1)
|
|
self.bank.db.commit()
|
|
self.assertEqual(self.refresh(live).status_code, 200)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|