Add authentication

Proving who somebody is and starting a session for them are separate
operations. login_with_caldav does the first by asking the CalDAV server
whether the credentials work; begin_session does the second and knows
nothing about how the question was answered. OIDC arrives as a second way
to reach begin_session, not as a second scheme threaded through everything
-- which is what v1 had, with a JWT for most of the app and a separate
SQLite session_token used only by the preferences API.

The token lives in an HttpOnly cookie and nowhere else. v1 kept a JWT and
the CalDAV password in localStorage, readable by any script on the origin,
and re-sent the password in a header on every request. Here the password
never leaves the server: it is encrypted with XChaCha20-Poly1305 and
caldav_for is the only path back, handing out a client rather than a
credential.

Failed decryption is an error, not a subtly wrong password -- the AEAD tag
is checked, so a tampered row surfaces here instead of as a mysterious
CalDAV rejection later. A wrong password and an unreachable server stay
distinct, because telling somebody their password is wrong when the server
is down sends them to reset one that was fine.

Errors carry a stable code alongside their message, so a client can branch
on them. Internal ones say nothing about the inside of the server; the
detail goes to the log.

Tests go through router(), the same function main calls -- v1's suite
rebuilt the route table and tested a copy until it stopped compiling.
Skipping is now loud: a skipped test reports "ok", so run.sh sets
RUNWAY_REQUIRE_CALDAV=1 and not running becomes a failure.
This commit is contained in:
2026-08-26 16:27:59 -04:00
parent 82d05dc67a
commit c7e22f4431
19 changed files with 1895 additions and 13 deletions
+4
View File
@@ -42,6 +42,9 @@ rrule = "0.14"
# Crypto
sha2 = "0.10"
chacha20poly1305 = "0.10"
rand = "0.9"
base64 = "0.22"
# Errors + logging
thiserror = "2"
@@ -50,6 +53,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Backend
axum = "0.8"
axum-extra = { version = "0.10", features = ["cookie"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace"] }