From c7e22f4431f8719d4270d406e59926b8252f7313 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Wed, 26 Aug 2026 16:27:59 -0400 Subject: [PATCH] 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. --- Cargo.lock | 285 +++++++++- Cargo.toml | 4 + README.md | 14 + crates/runway-caldav/tests/baikal/run.sh | 6 + crates/runway-caldav/tests/live.rs | 23 +- crates/runway-server/Cargo.toml | 6 + crates/runway-server/src/app.rs | 22 + crates/runway-server/src/auth/cookie.rs | 47 ++ crates/runway-server/src/auth/crypto.rs | 151 +++++ crates/runway-server/src/auth/extract.rs | 45 ++ crates/runway-server/src/auth/mod.rs | 196 +++++++ crates/runway-server/src/config.rs | 85 +++ crates/runway-server/src/error.rs | 119 ++++ crates/runway-server/src/lib.rs | 10 + crates/runway-server/src/main.rs | 67 ++- crates/runway-server/src/routes/auth.rs | 98 ++++ crates/runway-server/src/routes/mod.rs | 12 + crates/runway-server/src/state.rs | 37 ++ crates/runway-server/tests/auth.rs | 681 +++++++++++++++++++++++ 19 files changed, 1895 insertions(+), 13 deletions(-) create mode 100644 crates/runway-server/src/app.rs create mode 100644 crates/runway-server/src/auth/cookie.rs create mode 100644 crates/runway-server/src/auth/crypto.rs create mode 100644 crates/runway-server/src/auth/extract.rs create mode 100644 crates/runway-server/src/auth/mod.rs create mode 100644 crates/runway-server/src/config.rs create mode 100644 crates/runway-server/src/error.rs create mode 100644 crates/runway-server/src/routes/auth.rs create mode 100644 crates/runway-server/src/routes/mod.rs create mode 100644 crates/runway-server/src/state.rs create mode 100644 crates/runway-server/tests/auth.rs diff --git a/Cargo.lock b/Cargo.lock index 2d167e9..8b41cc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.6", + "generic-array", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -224,6 +234,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base16" version = "0.2.1" @@ -309,6 +342,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.1" @@ -317,7 +361,20 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.1", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", ] [[package]] @@ -345,6 +402,17 @@ dependencies = [ "serde", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.6", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "4.6.6" @@ -487,6 +555,17 @@ dependencies = [ "convert_case 0.11.0", ] +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -548,6 +627,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -569,6 +649,12 @@ dependencies = [ "cmov", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "derive-where" version = "1.6.1" @@ -855,6 +941,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -864,8 +962,8 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", - "rand_core", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -1249,6 +1347,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "interpolator" version = "0.5.0" @@ -1637,6 +1744,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -1668,6 +1781,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "or_poisoned" version = "0.1.0" @@ -1771,6 +1890,17 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -1780,6 +1910,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -1893,7 +2038,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", @@ -1951,21 +2096,65 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.1", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", ] [[package]] @@ -1980,7 +2169,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -2206,8 +2395,12 @@ name = "runway-server" version = "0.1.0" dependencies = [ "axum", + "axum-extra", + "base64", + "chacha20poly1305", "chrono", "pretty_assertions", + "rand 0.9.5", "reqwest", "runway-caldav", "runway-core", @@ -2216,6 +2409,7 @@ dependencies = [ "sha2 0.10.9", "sqlx", "thiserror 2.0.20", + "time", "tokio", "tower", "tower-http", @@ -2719,7 +2913,7 @@ dependencies = [ "log", "md-5", "memchr", - "rand", + "rand 0.10.2", "serde", "serde_json", "sha2 0.11.0", @@ -2931,6 +3125,36 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -3215,6 +3439,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.6", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -3300,6 +3534,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -3584,6 +3827,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.4" @@ -3625,6 +3874,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index 53593b9..f9ca936 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/README.md b/README.md index 6a36828..2bbae4a 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,20 @@ tears it down: crates/runway-caldav/tests/baikal/run.sh ``` +Running the backend needs an encryption key for stored CalDAV credentials. It +must stay the same across restarts — a key invented at startup would silently +make every saved credential unreadable — so the server refuses to start without +one rather than generating a throwaway: + +```sh +export RUNWAY_SECRET_KEY=$(cargo run -q -p runway-server -- genkey) +export RUNWAY_DATABASE_URL=sqlite:runway.db # default +export RUNWAY_BIND=0.0.0.0:3000 # default +export RUNWAY_INSECURE_COOKIES=1 # local HTTP only + +cargo run -p runway-server +``` + The CLI drives the same stack the app does, which makes it the quickest way to tell a display bug from a data one: diff --git a/crates/runway-caldav/tests/baikal/run.sh b/crates/runway-caldav/tests/baikal/run.sh index 6dd8a5e..39462a4 100755 --- a/crates/runway-caldav/tests/baikal/run.sh +++ b/crates/runway-caldav/tests/baikal/run.sh @@ -49,5 +49,11 @@ python3 "$HERE/setup.py" "http://localhost:$PORT" "$USERNAME" "$PASSWORD" export RUNWAY_CALDAV_URL="http://localhost:$PORT/dav.php/" export RUNWAY_CALDAV_USER="$USERNAME" export RUNWAY_CALDAV_PASSWORD="$PASSWORD" +# Turns a silent skip into a failure: see the note in live.rs. +export RUNWAY_REQUIRE_CALDAV=1 +# Both suites: the CalDAV client against the server, and the backend's own +# login flow against it. Skipped tests report as "ok", so the only way to know +# they ran is to run them here. cargo test -p runway-caldav --test live -- --test-threads=1 "$@" +cargo test -p runway-server --test auth -- --test-threads=1 "$@" diff --git a/crates/runway-caldav/tests/live.rs b/crates/runway-caldav/tests/live.rs index f2dd095..8cba88d 100644 --- a/crates/runway-caldav/tests/live.rs +++ b/crates/runway-caldav/tests/live.rs @@ -25,10 +25,27 @@ use runway_core::ical; use runway_core::model::{CalendarDateTime, TzId, VCalendar, VEvent}; /// A client, or `None` when no server is configured. +/// +/// A skipped test still reports "ok", which is the kind of quiet no-op this +/// project exists to stop shipping. `RUNWAY_REQUIRE_CALDAV=1` -- which +/// `run.sh` sets -- turns "no server" into a failure, so in the one place +/// these are meant to run, not running is loud. fn client() -> Option<(CalDavClient, String)> { - let url = std::env::var("RUNWAY_CALDAV_URL").ok()?; - let user = std::env::var("RUNWAY_CALDAV_USER").ok()?; - let password = std::env::var("RUNWAY_CALDAV_PASSWORD").ok()?; + let details = (|| { + Some(( + std::env::var("RUNWAY_CALDAV_URL").ok()?, + std::env::var("RUNWAY_CALDAV_USER").ok()?, + std::env::var("RUNWAY_CALDAV_PASSWORD").ok()?, + )) + })(); + + let required = std::env::var("RUNWAY_REQUIRE_CALDAV").is_ok_and(|v| v == "1"); + assert!( + details.is_some() || !required, + "RUNWAY_REQUIRE_CALDAV is set but no server is configured", + ); + + let (url, user, password) = details?; let client = CalDavClient::new(&url, Credentials::new(&user, password)) .expect("the configured CalDAV URL is not valid"); Some((client, user)) diff --git a/crates/runway-server/Cargo.toml b/crates/runway-server/Cargo.toml index 92cd735..519c263 100644 --- a/crates/runway-server/Cargo.toml +++ b/crates/runway-server/Cargo.toml @@ -9,6 +9,11 @@ description = "Axum backend: CalDAV proxy, sessions, preferences, ICS feeds." runway-core = { workspace = true, features = ["ical", "recurrence"] } runway-caldav = { workspace = true } axum = { workspace = true } +axum-extra = { workspace = true } +base64 = { workspace = true } +chacha20poly1305 = { workspace = true } +time = "0.3" +rand = { workspace = true } chrono = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -25,6 +30,7 @@ uuid = { workspace = true } [dev-dependencies] reqwest = { workspace = true } pretty_assertions = { workspace = true } +tower = { workspace = true, features = ["util"] } [lints] workspace = true diff --git a/crates/runway-server/src/app.rs b/crates/runway-server/src/app.rs new file mode 100644 index 0000000..61debec --- /dev/null +++ b/crates/runway-server/src/app.rs @@ -0,0 +1,22 @@ +//! The router. +//! +//! One function, used by both `main` and the tests. v1's integration suite +//! rebuilt the route table itself, which meant it tested a copy — and when the +//! real one changed, the copy silently kept passing until it stopped compiling +//! altogether. + +use crate::routes; +use crate::state::AppState; +use axum::Router; +use axum::routing::{get, post}; +use tower_http::trace::TraceLayer; + +pub fn router(state: AppState) -> Router { + Router::new() + .route("/api/health", get(routes::health)) + .route("/api/auth/login", post(routes::auth::login)) + .route("/api/auth/logout", post(routes::auth::logout)) + .route("/api/auth/session", get(routes::auth::current_session)) + .layer(TraceLayer::new_for_http()) + .with_state(state) +} diff --git a/crates/runway-server/src/auth/cookie.rs b/crates/runway-server/src/auth/cookie.rs new file mode 100644 index 0000000..431067c --- /dev/null +++ b/crates/runway-server/src/auth/cookie.rs @@ -0,0 +1,47 @@ +//! The session cookie. +//! +//! `HttpOnly`, so no script can read it. That single attribute is the whole +//! reason for the change of approach: v1 kept a JWT *and* the CalDAV password +//! in `localStorage`, which is readable by any script on the origin, and the +//! password was then re-sent on every request in a header. Here the browser +//! holds an opaque token it cannot read, and the password never leaves the +//! server at all. + +use axum_extra::extract::cookie::{Cookie, SameSite}; +use chrono::TimeDelta; + +/// The cookie's name. +pub const SESSION_COOKIE: &str = "runway_session"; + +/// Builds the cookie that carries a session. +/// +/// `SameSite=Lax` rather than `Strict`: the app is a single origin and `Lax` +/// still blocks cross-site POSTs, while `Strict` would drop the cookie when +/// someone follows a link into the calendar from elsewhere and make it look +/// like they had been logged out. +pub fn session_cookie(token: String, lifetime: TimeDelta, secure: bool) -> Cookie<'static> { + let mut cookie = Cookie::new(SESSION_COOKIE, token); + cookie.set_http_only(true); + cookie.set_secure(secure); + cookie.set_same_site(SameSite::Lax); + cookie.set_path("/"); + cookie.set_max_age( + time::Duration::try_from(lifetime.to_std().unwrap_or_default()) + .unwrap_or(time::Duration::days(1)), + ); + cookie +} + +/// Builds the cookie that ends a session. +/// +/// Same name, same path, empty value, expired. All four have to match or the +/// browser keeps the original and the logout appears to do nothing. +pub fn clear_session_cookie(secure: bool) -> Cookie<'static> { + let mut cookie = Cookie::new(SESSION_COOKIE, ""); + cookie.set_http_only(true); + cookie.set_secure(secure); + cookie.set_same_site(SameSite::Lax); + cookie.set_path("/"); + cookie.set_max_age(time::Duration::ZERO); + cookie +} diff --git a/crates/runway-server/src/auth/crypto.rs b/crates/runway-server/src/auth/crypto.rs new file mode 100644 index 0000000..0e3f59a --- /dev/null +++ b/crates/runway-server/src/auth/crypto.rs @@ -0,0 +1,151 @@ +//! Encrypting the CalDAV password at rest, and minting session tokens. +//! +//! Runway has to be able to *use* the CalDAV password — it proxies requests on +//! the user's behalf — so it cannot be hashed. Encryption is the honest option +//! for a secret that must be recoverable, and this module is the only thing +//! that can do either direction. +//! +//! The alternative v1 chose was to not store it at all and have the browser +//! re-send it on every request. That sounds safer and is worse: the password +//! ended up in `localStorage` in cleartext, readable by any script on the +//! origin and read from eight separate call sites. + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use chacha20poly1305::aead::{Aead, KeyInit}; +use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce}; +use rand::TryRngCore; +use rand::rngs::OsRng; +use thiserror::Error; + +/// The algorithm name recorded alongside every ciphertext. +/// +/// Stored per row so a future change of scheme can tell old rows from new ones +/// instead of guessing. +pub const ALGORITHM: &str = "xchacha20poly1305"; + +/// The server's encryption key. +/// +/// `Debug` is deliberate: a derived one would print the key into any trace that +/// happened to include the configuration. +#[derive(Clone)] +pub struct SecretKey(Key); + +impl std::fmt::Debug for SecretKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SecretKey()") + } +} + +impl SecretKey { + /// Reads a key from its configured form: 32 bytes, as hex or as base64. + /// + /// Both accepted because both are what a person reaches for, and rejecting + /// the one they picked teaches nothing. + pub fn parse(text: &str) -> Result { + let text = text.trim(); + let bytes = if text.len() == 64 && text.bytes().all(|b| b.is_ascii_hexdigit()) { + (0..32) + .map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16)) + .collect::, _>>() + .map_err(|_| KeyError::Malformed)? + } else { + URL_SAFE_NO_PAD + .decode(text) + .or_else(|_| base64::engine::general_purpose::STANDARD.decode(text)) + .map_err(|_| KeyError::Malformed)? + }; + + let bytes: [u8; 32] = bytes + .as_slice() + .try_into() + .map_err(|_| KeyError::WrongLength(bytes.len()))?; + Ok(Self(Key::from(bytes))) + } + + /// A fresh random key, for `runway-server genkey` and for tests. + pub fn generate() -> Result { + Ok(Self(Key::from(random_bytes::<32>()?))) + } + + /// The key in the form the configuration expects. + /// + /// Named to make its use obvious at the call site; there is no `Display` + /// and no `Serialize`, so it cannot leak by accident. + pub fn expose_for_config(&self) -> String { + URL_SAFE_NO_PAD.encode(self.0) + } + + /// Encrypts a secret, returning ciphertext and the nonce it used. + /// + /// A fresh random nonce every time. XChaCha20's 192-bit nonce is large + /// enough that random generation cannot realistically repeat, which is why + /// it is used here rather than the 96-bit variant that would need a counter + /// and somewhere to keep it. + pub fn encrypt(&self, plaintext: &[u8]) -> Result<(Vec, Vec), CryptoError> { + let cipher = XChaCha20Poly1305::new(&self.0); + let nonce_bytes = random_bytes::<24>()?; + let nonce = XNonce::from(nonce_bytes); + + let ciphertext = cipher + .encrypt(&nonce, plaintext) + .map_err(|_| CryptoError::Encrypt)?; + Ok((ciphertext, nonce_bytes.to_vec())) + } + + /// Decrypts what [`Self::encrypt`] produced. + /// + /// Fails rather than returning garbage if the ciphertext was altered: the + /// AEAD tag is checked, so a tampered or truncated row is an error and not + /// a password that is subtly wrong. + pub fn decrypt(&self, ciphertext: &[u8], nonce: &[u8]) -> Result, CryptoError> { + let nonce: [u8; 24] = nonce.try_into().map_err(|_| CryptoError::BadNonce)?; + let cipher = XChaCha20Poly1305::new(&self.0); + cipher + .decrypt(&XNonce::from(nonce), ciphertext) + .map_err(|_| CryptoError::Decrypt) + } +} + +/// A random session token, for the cookie. +/// +/// 32 bytes from the operating system, base64url encoded. Only the hash of this +/// reaches the database. +pub fn session_token() -> Result { + Ok(URL_SAFE_NO_PAD.encode(random_bytes::<32>()?)) +} + +/// Bytes from the operating system's generator. +/// +/// Fallible rather than panicking. A kernel that cannot supply randomness is +/// not a condition to carry on through -- every key, nonce and session token +/// after it would be predictable -- but it is also not a reason to take the +/// process down mid-request when a 500 and a log line say the same thing. +fn random_bytes() -> Result<[u8; N], CryptoError> { + let mut bytes = [0u8; N]; + OsRng + .try_fill_bytes(&mut bytes) + .map_err(|_| CryptoError::NoRandomness)?; + Ok(bytes) +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum KeyError { + #[error("the secret key must be 32 bytes as hex or base64")] + Malformed, + #[error("the secret key must be 32 bytes, got {0}")] + WrongLength(usize), +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum CryptoError { + #[error("could not read randomness from the operating system")] + NoRandomness, + #[error("could not encrypt")] + Encrypt, + /// Wrong key, or the stored row was altered. + #[error("could not decrypt the stored credential")] + Decrypt, + #[error("the stored nonce is the wrong size")] + BadNonce, +} diff --git a/crates/runway-server/src/auth/extract.rs b/crates/runway-server/src/auth/extract.rs new file mode 100644 index 0000000..28abab7 --- /dev/null +++ b/crates/runway-server/src/auth/extract.rs @@ -0,0 +1,45 @@ +//! The `CurrentUser` extractor. +//! +//! A handler that needs a logged-in user says so in its signature and receives +//! one; a handler that does not, cannot accidentally get one. That is the whole +//! access-control model, and it is checked by the compiler rather than by +//! remembering to call something first. + +use super::{AuthError, SESSION_COOKIE}; +use crate::db::{Session, User}; +use crate::error::ApiError; +use crate::state::AppState; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use axum_extra::extract::CookieJar; + +/// A user proven to be logged in, and the session that proved it. +#[derive(Debug, Clone)] +pub struct CurrentUser { + pub user: User, + pub session: Session, +} + +impl FromRequestParts for CurrentUser { + type Rejection = ApiError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let jar = CookieJar::from_headers(&parts.headers); + let token = jar + .get(SESSION_COOKIE) + .map(|cookie| cookie.value().to_owned()) + .ok_or(ApiError::Unauthenticated)?; + + match state.auth.authenticate(&token).await { + Ok(Some((user, session))) => Ok(Self { user, session }), + // An expired or unknown token is not an error to explain, it is + // simply not being logged in. + Ok(None) => Err(ApiError::Unauthenticated), + Err(AuthError::Database(error)) => Err(ApiError::Internal(error.to_string())), + Err(error) => Err(ApiError::Internal(error.to_string())), + } + } +} diff --git a/crates/runway-server/src/auth/mod.rs b/crates/runway-server/src/auth/mod.rs new file mode 100644 index 0000000..4020261 --- /dev/null +++ b/crates/runway-server/src/auth/mod.rs @@ -0,0 +1,196 @@ +//! Authentication. +//! +//! Two things are deliberately separate here, and the separation is the point. +//! +//! **Proving who someone is** and **starting a session for them** are different +//! operations. [`AuthService::login_with_caldav`] does the first by asking the +//! CalDAV server whether the credentials work; [`AuthService::begin_session`] +//! does the second and knows nothing about how the question was answered. +//! Adding OIDC later means adding a second way to reach `begin_session`, not +//! threading a second scheme through everything. +//! +//! v1 had the opposite: a JWT in `localStorage` for most of the app and a +//! *separate* SQLite-backed `session_token` used only by the preferences API — +//! two auth schemes for one application, neither of which could be revoked. + +mod cookie; +mod crypto; +mod extract; + +pub use cookie::{SESSION_COOKIE, clear_session_cookie, session_cookie}; +pub use crypto::{ALGORITHM, CryptoError, KeyError, SecretKey, session_token}; +pub use extract::CurrentUser; + +use crate::db::{Database, DbError, Session, User}; +use chrono::TimeDelta; +use runway_caldav::{CalDavClient, CalDavError, Credentials}; +use thiserror::Error; + +/// Everything needed to authenticate a request or start a session. +#[derive(Debug, Clone)] +pub struct AuthService { + db: Database, + secret: SecretKey, + lifetime: TimeDelta, +} + +impl AuthService { + pub fn new(db: Database, secret: SecretKey, lifetime: TimeDelta) -> Self { + Self { + db, + secret, + lifetime, + } + } + + pub fn session_lifetime(&self) -> TimeDelta { + self.lifetime + } + + /// Logs in by proving the credentials work against the CalDAV server. + /// + /// "Can we list your calendars?" is the whole test, which is the same thing + /// v1 did and one of the few things it got right: there is no separate + /// password to forget, and an account that stops working on the CalDAV + /// server stops working here. + /// + /// The password is then encrypted and stored, because every later request + /// is made on the user's behalf against that same server. + pub async fn login_with_caldav( + &self, + server_url: &str, + username: &str, + password: &str, + user_agent: Option<&str>, + ) -> Result<(User, String), AuthError> { + let client = CalDavClient::new(server_url, Credentials::new(username, password)) + .map_err(|_| AuthError::BadServerUrl)?; + + // The verification. Anything other than success is a failed login as + // far as the caller is concerned, but the distinction between "wrong + // password" and "server unreachable" is kept, because they need + // different things from the person reading the message. + match client.current_user_principal().await { + Ok(_) => {} + Err(CalDavError::Unauthorized) => return Err(AuthError::InvalidCredentials), + Err(error) => return Err(AuthError::Unreachable(error.to_string())), + } + + let user = self + .db + .users() + .record_login(username, server_url, None) + .await?; + + let (ciphertext, nonce) = self.secret.encrypt(password.as_bytes())?; + self.db + .credentials() + .store(&user.id, &ciphertext, &nonce, ALGORITHM) + .await?; + + let token = self.begin_session(&user, user_agent).await?; + Ok((user, token)) + } + + /// Issues a session for a user whose identity is already established. + /// + /// Knows nothing about how that happened. This is the seam OIDC arrives at: + /// a second issuer authenticates the person and calls this, and everything + /// downstream — the cookie, the middleware, expiry, logout — is unchanged. + pub async fn begin_session( + &self, + user: &User, + user_agent: Option<&str>, + ) -> Result { + let token = session_token()?; + self.db + .sessions() + .create(&user.id, &token, self.lifetime, user_agent) + .await?; + Ok(token) + } + + /// Resolves a session token to the user it belongs to. + /// + /// Expired and unknown tokens are both simply `None`: there is nothing a + /// caller should do differently, and collapsing them removes a branch where + /// a mistake would let an expired session through. + pub async fn authenticate(&self, token: &str) -> Result, AuthError> { + let Some(session) = self.db.sessions().touch(token).await? else { + return Ok(None); + }; + // A session whose user has been deleted is not a session. The cascade + // should have removed it; this is the belt to that braces. + let Some(user) = self.db.users().find(&session.user_id).await? else { + return Ok(None); + }; + Ok(Some((user, session))) + } + + /// Ends one session. + pub async fn logout(&self, token: &str) -> Result<(), AuthError> { + self.db.sessions().delete(token).await?; + Ok(()) + } + + /// Ends every session a user has, and forgets their stored credential. + pub async fn forget(&self, user: &User) -> Result<(), AuthError> { + self.db.sessions().delete_for_user(&user.id).await?; + self.db.credentials().delete(&user.id).await?; + Ok(()) + } + + /// A CalDAV client acting as a logged-in user. + /// + /// The only path from the stored ciphertext back to a usable password, and + /// the password never leaves this function — callers get a client, not a + /// credential. + pub async fn caldav_for(&self, user: &User) -> Result { + let stored = self + .db + .credentials() + .load(&user.id) + .await? + .ok_or(AuthError::NoStoredCredential)?; + + if stored.algorithm != ALGORITHM { + return Err(AuthError::UnknownAlgorithm(stored.algorithm)); + } + + let plaintext = self.secret.decrypt(&stored.ciphertext, &stored.nonce)?; + let password = + String::from_utf8(plaintext).map_err(|_| AuthError::Crypto(CryptoError::Decrypt))?; + + CalDavClient::new(&user.server_url, Credentials::new(&user.username, password)) + .map_err(|_| AuthError::BadServerUrl) + } +} + +#[derive(Debug, Error)] +pub enum AuthError { + #[error("that username or password was not accepted by the CalDAV server")] + InvalidCredentials, + + /// Kept apart from `InvalidCredentials` on purpose: telling somebody their + /// password is wrong when the server is down sends them to reset a password + /// that was fine. + #[error("could not reach the CalDAV server: {0}")] + Unreachable(String), + + #[error("that does not look like a CalDAV server URL")] + BadServerUrl, + + /// The account exists but has no password on file — possible once a second + /// issuer can create sessions without one. + #[error("no CalDAV credential is stored for this account")] + NoStoredCredential, + + #[error("the stored credential uses an unknown scheme {0:?}")] + UnknownAlgorithm(String), + + #[error(transparent)] + Crypto(#[from] CryptoError), + + #[error(transparent)] + Database(#[from] DbError), +} diff --git a/crates/runway-server/src/config.rs b/crates/runway-server/src/config.rs new file mode 100644 index 0000000..4130ae3 --- /dev/null +++ b/crates/runway-server/src/config.rs @@ -0,0 +1,85 @@ +//! Server configuration, read from the environment. + +use crate::auth::{KeyError, SecretKey}; +use chrono::TimeDelta; +use std::net::SocketAddr; +use thiserror::Error; + +#[derive(Debug, Clone)] +pub struct Config { + pub database_url: String, + pub bind: SocketAddr, + /// Encrypts stored CalDAV credentials. + pub secret_key: SecretKey, + pub session_lifetime: TimeDelta, + /// Whether to mark the session cookie `Secure`. + /// + /// A `Secure` cookie is not sent over plain HTTP, so leaving this on during + /// local development produces a login that appears to succeed and then does + /// nothing. Off only when explicitly asked. + pub secure_cookies: bool, +} + +impl Config { + pub fn from_env() -> Result { + let secret = std::env::var("RUNWAY_SECRET_KEY").map_err(|_| ConfigError::MissingKey)?; + + Ok(Self { + database_url: std::env::var("RUNWAY_DATABASE_URL") + .unwrap_or_else(|_| "sqlite:runway.db".to_owned()), + bind: std::env::var("RUNWAY_BIND") + .unwrap_or_else(|_| "0.0.0.0:3000".to_owned()) + .parse() + .map_err(|_| ConfigError::BadBind)?, + secret_key: SecretKey::parse(&secret)?, + session_lifetime: TimeDelta::try_hours( + std::env::var("RUNWAY_SESSION_HOURS") + .ok() + .and_then(|h| h.parse().ok()) + .unwrap_or(24 * 14), + ) + .ok_or(ConfigError::BadSessionLifetime)?, + secure_cookies: !matches!( + std::env::var("RUNWAY_INSECURE_COOKIES").as_deref(), + Ok("1" | "true") + ), + }) + } + + /// A configuration for tests: a private database, a throwaway key. + pub fn for_tests() -> Self { + Self { + database_url: "sqlite::memory:".to_owned(), + bind: "127.0.0.1:0" + .parse() + .unwrap_or(SocketAddr::from(([127, 0, 0, 1], 0))), + secret_key: SecretKey::generate().unwrap_or_else(|e| panic!("{e}")), + session_lifetime: TimeDelta::hours(24), + secure_cookies: false, + } + } +} + +#[derive(Debug, Error)] +pub enum ConfigError { + /// Deliberately fatal rather than generating a key on the fly. + /// + /// A key invented at startup changes on every restart, and every stored + /// credential silently stops decrypting. Better to refuse to start and say + /// how to make one. + #[error( + "RUNWAY_SECRET_KEY is not set. It encrypts stored CalDAV passwords and must \ + stay the same across restarts, or every saved credential becomes unreadable. \ + Generate one with: runway-server genkey" + )] + MissingKey, + + #[error("RUNWAY_SECRET_KEY is unusable: {0}")] + Key(#[from] KeyError), + + #[error("RUNWAY_BIND is not a socket address, e.g. 0.0.0.0:3000")] + BadBind, + + #[error("RUNWAY_SESSION_HOURS is out of range")] + BadSessionLifetime, +} diff --git a/crates/runway-server/src/error.rs b/crates/runway-server/src/error.rs new file mode 100644 index 0000000..d943262 --- /dev/null +++ b/crates/runway-server/src/error.rs @@ -0,0 +1,119 @@ +//! API errors. +//! +//! One shape for every failure, so a client can match on `code` rather than +//! parse prose. v1 returned `Result` at nearly every boundary, which +//! left the frontend unable to tell a 401 from a JSON parse failure and with +//! nothing to do but display the text. + +use axum::Json; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; + +#[derive(Debug)] +pub enum ApiError { + /// No session, or one that has run out. + Unauthenticated, + /// Logged in, but not allowed to do this. + Forbidden, + /// The credentials offered at login were not accepted. + InvalidCredentials, + /// The CalDAV server could not be reached or refused. + Upstream(String), + /// The request itself was wrong. + BadRequest(String), + NotFound(String), + /// Somebody else changed the thing being written. + Conflict(String), + /// Anything unexpected. The detail is logged, not returned. + Internal(String), +} + +impl ApiError { + fn parts(&self) -> (StatusCode, &'static str, String) { + match self { + Self::Unauthenticated => ( + StatusCode::UNAUTHORIZED, + "unauthenticated", + "you are not signed in".to_owned(), + ), + Self::Forbidden => ( + StatusCode::FORBIDDEN, + "forbidden", + "you do not have access to that".to_owned(), + ), + Self::InvalidCredentials => ( + StatusCode::UNAUTHORIZED, + "invalid_credentials", + "that username or password was not accepted by the CalDAV server".to_owned(), + ), + Self::Upstream(detail) => (StatusCode::BAD_GATEWAY, "upstream", detail.clone()), + Self::BadRequest(detail) => (StatusCode::BAD_REQUEST, "bad_request", detail.clone()), + Self::NotFound(detail) => (StatusCode::NOT_FOUND, "not_found", detail.clone()), + Self::Conflict(detail) => (StatusCode::CONFLICT, "conflict", detail.clone()), + Self::Internal(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal", + // Deliberately generic. The detail goes to the log, where it is + // useful, rather than to the client, where it is a hint about + // the inside of the server. + "something went wrong on the server".to_owned(), + ), + } + } +} + +#[derive(Serialize)] +struct ErrorBody { + /// A stable identifier the client can branch on. + code: &'static str, + /// Text fit to show a person. + message: String, +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let (status, code, message) = self.parts(); + + if let Self::Internal(detail) = &self { + tracing::error!(code, detail, "request failed"); + } else { + tracing::debug!(code, %message, "request rejected"); + } + + (status, Json(ErrorBody { code, message })).into_response() + } +} + +impl From for ApiError { + fn from(error: crate::db::DbError) -> Self { + Self::Internal(error.to_string()) + } +} + +impl From for ApiError { + fn from(error: crate::auth::AuthError) -> Self { + use crate::auth::AuthError; + match error { + AuthError::InvalidCredentials => Self::InvalidCredentials, + AuthError::Unreachable(detail) => Self::Upstream(detail), + AuthError::BadServerUrl => { + Self::BadRequest("that does not look like a CalDAV server URL".to_owned()) + } + AuthError::NoStoredCredential => Self::Unauthenticated, + other => Self::Internal(other.to_string()), + } + } +} + +impl From for ApiError { + fn from(error: runway_caldav::CalDavError) -> Self { + use runway_caldav::CalDavError; + match error { + CalDavError::Unauthorized => Self::Unauthenticated, + CalDavError::NotFound { href } => Self::NotFound(href), + CalDavError::Conflict { href } => Self::Conflict(href), + other => Self::Upstream(other.to_string()), + } + } +} diff --git a/crates/runway-server/src/lib.rs b/crates/runway-server/src/lib.rs index 5d0cf73..65f8d03 100644 --- a/crates/runway-server/src/lib.rs +++ b/crates/runway-server/src/lib.rs @@ -1,3 +1,13 @@ //! Runway backend: a CalDAV proxy with sessions, preferences and ICS feeds. +pub mod app; +pub mod auth; +pub mod config; pub mod db; +pub mod error; +pub mod routes; +pub mod state; + +pub use app::router; +pub use config::{Config, ConfigError}; +pub use state::AppState; diff --git a/crates/runway-server/src/main.rs b/crates/runway-server/src/main.rs index ca09b7a..340409d 100644 --- a/crates/runway-server/src/main.rs +++ b/crates/runway-server/src/main.rs @@ -1,3 +1,66 @@ -fn main() { - // Server startup lands in M7, once sessions and the CalDAV proxy exist. +//! Starts the server. + +use runway_server::auth::SecretKey; +use runway_server::{AppState, Config, router}; +use std::process::ExitCode; + +#[tokio::main] +async fn main() -> ExitCode { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "runway_server=info,tower_http=warn".into()), + ) + .init(); + + // One subcommand, because the alternative is telling people to run an + // openssl incantation and hoping they get the length right. + if std::env::args().nth(1).as_deref() == Some("genkey") { + return match SecretKey::generate() { + Ok(key) => { + println!("{}", key.expose_for_config()); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("error: {error}"); + ExitCode::FAILURE + } + }; + } + + match run().await { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("error: {error}"); + ExitCode::FAILURE + } + } +} + +async fn run() -> Result<(), Box> { + let config = Config::from_env()?; + let bind = config.bind; + + if !config.secure_cookies { + tracing::warn!( + "RUNWAY_INSECURE_COOKIES is set: the session cookie will be sent over \ + plain HTTP. Only appropriate for local development." + ); + } + + let state = AppState::new(config).await?; + let listener = tokio::net::TcpListener::bind(bind).await?; + tracing::info!(%bind, "listening"); + + axum::serve(listener, router(state)) + .with_graceful_shutdown(shutdown()) + .await?; + Ok(()) +} + +/// Lets in-flight requests finish on Ctrl-C, so a restart does not drop a save +/// half-way through a CalDAV write. +async fn shutdown() { + let _ = tokio::signal::ctrl_c().await; + tracing::info!("shutting down"); } diff --git a/crates/runway-server/src/routes/auth.rs b/crates/runway-server/src/routes/auth.rs new file mode 100644 index 0000000..6cc425a --- /dev/null +++ b/crates/runway-server/src/routes/auth.rs @@ -0,0 +1,98 @@ +//! Signing in and out. + +use crate::auth::{CurrentUser, clear_session_cookie, session_cookie}; +use crate::db::User; +use crate::error::ApiError; +use crate::state::AppState; +use axum::Json; +use axum::extract::State; +use axum::http::HeaderMap; +use axum::http::header::USER_AGENT; +use axum::response::{IntoResponse, Response}; +use axum_extra::extract::CookieJar; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize)] +pub struct LoginRequest { + /// The DAV root, e.g. `https://example.com/dav.php/`. + pub server_url: String, + pub username: String, + pub password: String, +} + +/// What a signed-in client is told about itself. +/// +/// The [`User`] row is returned directly rather than copied into a parallel +/// response struct. It holds nothing secret — the password lives encrypted in +/// its own table and never travels — and v1's four near-identical wire structs +/// are a warning about what maintaining a second shape costs. +#[derive(Serialize)] +pub struct SessionResponse { + pub user: User, +} + +/// `POST /api/auth/login` +/// +/// Verification is a real request to the CalDAV server: if it will list your +/// calendars, you are who you say you are. There is no Runway password to +/// forget, and revoking an account on the CalDAV server revokes it here. +pub async fn login( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + Json(request): Json, +) -> Result { + if request.username.is_empty() || request.password.is_empty() { + return Err(ApiError::BadRequest( + "a username and password are both required".to_owned(), + )); + } + + let user_agent = headers + .get(USER_AGENT) + .and_then(|value| value.to_str().ok()); + + let (user, token) = state + .auth + .login_with_caldav( + request.server_url.trim(), + request.username.trim(), + &request.password, + user_agent, + ) + .await?; + + // The token goes in an HttpOnly cookie and nowhere else. It is deliberately + // absent from the body: anything in the body is readable by script, which + // is the property the cookie exists to avoid. + let jar = jar.add(session_cookie( + token, + state.auth.session_lifetime(), + state.config.secure_cookies, + )); + + Ok((jar, Json(SessionResponse { user })).into_response()) +} + +/// `POST /api/auth/logout` +/// +/// Ends the session server-side as well as clearing the cookie. Clearing only +/// the cookie would leave a token that still works for anyone holding a copy. +pub async fn logout(State(state): State, jar: CookieJar) -> Result { + if let Some(cookie) = jar.get(crate::auth::SESSION_COOKIE) { + state.auth.logout(cookie.value()).await?; + } + + let jar = jar.add(clear_session_cookie(state.config.secure_cookies)); + Ok((jar, Json(serde_json::json!({ "status": "signed out" }))).into_response()) +} + +/// `GET /api/auth/session` +/// +/// Who the caller is. Used on startup to decide whether to show the login +/// screen, which replaces v1's separate token-verification endpoint. +pub async fn current_session( + CurrentUser { user, .. }: CurrentUser, +) -> Result, ApiError> { + Ok(Json(SessionResponse { user })) +} diff --git a/crates/runway-server/src/routes/mod.rs b/crates/runway-server/src/routes/mod.rs new file mode 100644 index 0000000..018a08d --- /dev/null +++ b/crates/runway-server/src/routes/mod.rs @@ -0,0 +1,12 @@ +//! HTTP handlers. + +pub mod auth; + +use axum::Json; +use serde_json::{Value, json}; + +/// Liveness. Says nothing about the database on purpose: a health check that +/// touches storage turns a slow query into an outage. +pub async fn health() -> Json { + json!({ "status": "ok", "service": "runway", "version": env!("CARGO_PKG_VERSION") }).into() +} diff --git a/crates/runway-server/src/state.rs b/crates/runway-server/src/state.rs new file mode 100644 index 0000000..7d76329 --- /dev/null +++ b/crates/runway-server/src/state.rs @@ -0,0 +1,37 @@ +//! What every handler is given. + +use crate::auth::AuthService; +use crate::config::Config; +use crate::db::Database; +use std::sync::Arc; + +#[derive(Debug, Clone)] +pub struct AppState { + pub db: Database, + pub auth: AuthService, + pub config: Arc, +} + +impl AppState { + pub async fn new(config: Config) -> Result { + let db = Database::connect(&config.database_url).await?; + Ok(Self::with_database(db, config)) + } + + /// Builds state around an existing database. + /// + /// Used by tests, so the suite exercises the same state the server runs + /// with rather than a parallel construction of it. + pub fn with_database(db: Database, config: Config) -> Self { + let auth = AuthService::new( + db.clone(), + config.secret_key.clone(), + config.session_lifetime, + ); + Self { + db, + auth, + config: Arc::new(config), + } + } +} diff --git a/crates/runway-server/tests/auth.rs b/crates/runway-server/tests/auth.rs new file mode 100644 index 0000000..68d2d49 --- /dev/null +++ b/crates/runway-server/tests/auth.rs @@ -0,0 +1,681 @@ +//! Authentication, through the real router. +//! +//! `router()` is the function `main` calls. v1's integration suite rebuilt the +//! route table itself, so it tested a copy — and when the real one changed, the +//! copy went on passing until it stopped compiling altogether and was left +//! broken. +//! +//! Tests that need a CalDAV server to log in against are skipped without one. +//! `crates/runway-caldav/tests/baikal/run.sh` starts one and runs them. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use chrono::TimeDelta; +use runway_server::auth::{SESSION_COOKIE, SecretKey}; +use runway_server::db::Database; +use runway_server::{AppState, Config, router}; +use serde_json::{Value, json}; +use tower::ServiceExt; + +// ------------------------------------------------------------------ harness -- + +struct Harness { + state: AppState, +} + +impl Harness { + async fn new() -> Self { + let db = Database::in_memory().await.unwrap(); + Self { + state: AppState::with_database(db, Config::for_tests()), + } + } + + async fn with_lifetime(lifetime: TimeDelta) -> Self { + let db = Database::in_memory().await.unwrap(); + let config = Config { + session_lifetime: lifetime, + ..Config::for_tests() + }; + Self { + state: AppState::with_database(db, config), + } + } + + /// Sends a request through the router the server actually serves. + async fn send(&self, request: Request) -> Reply { + let response = router(self.state.clone()) + .oneshot(request) + .await + .expect("the router must always produce a response"); + + let status = response.status(); + let cookies: Vec = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .map(str::to_owned) + .collect(); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .unwrap(); + let text = String::from_utf8_lossy(&bytes).into_owned(); + + Reply { + status, + cookies, + json: serde_json::from_str(&text).ok(), + text, + } + } + + async fn post(&self, path: &str, body: Value) -> Reply { + self.send( + Request::post(path) + .header(header::CONTENT_TYPE, "application/json") + .header(header::USER_AGENT, "runway-tests/1.0") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + } + + async fn get_with_session(&self, path: &str, token: &str) -> Reply { + self.send( + Request::get(path) + .header(header::COOKIE, format!("{SESSION_COOKIE}={token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + } + + async fn post_with_session(&self, path: &str, token: &str) -> Reply { + self.send( + Request::post(path) + .header(header::COOKIE, format!("{SESSION_COOKIE}={token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + } + + /// A user with a session, made without going through CalDAV. + /// + /// Exercises the seam OIDC will arrive at: identity established some other + /// way, then `begin_session`. + async fn signed_in_user(&self) -> (runway_server::db::User, String) { + let user = self + .state + .db + .users() + .record_login("alex", "https://dav.example.org/", Some("Alex")) + .await + .unwrap(); + let token = self + .state + .auth + .begin_session(&user, Some("runway-tests/1.0")) + .await + .unwrap(); + (user, token) + } +} + +struct Reply { + status: StatusCode, + cookies: Vec, + json: Option, + text: String, +} + +impl Reply { + fn session_cookie(&self) -> Option<&String> { + self.cookies + .iter() + .find(|c| c.starts_with(&format!("{SESSION_COOKIE}="))) + } + + /// The token the server put in the cookie. + fn token(&self) -> String { + let cookie = self.session_cookie().expect("no session cookie was set"); + cookie + .split(';') + .next() + .and_then(|pair| pair.split_once('=')) + .map(|(_, value)| value.to_owned()) + .expect("malformed session cookie") + } + + fn code(&self) -> Option<&str> { + self.json.as_ref()?.get("code")?.as_str() + } +} + +/// Whether a configured server is required. +/// +/// A skipped test still reports "ok", which is exactly the kind of quiet +/// no-op this project is meant to stop shipping. `run.sh` sets this, so in the +/// one place these are supposed to run, not running is a failure. +fn caldav_required() -> bool { + std::env::var("RUNWAY_REQUIRE_CALDAV").is_ok_and(|v| v == "1") +} + +/// Details of a live CalDAV server, when one is configured. +fn caldav() -> Option<(String, String, String)> { + let details = (|| { + Some(( + std::env::var("RUNWAY_CALDAV_URL").ok()?, + std::env::var("RUNWAY_CALDAV_USER").ok()?, + std::env::var("RUNWAY_CALDAV_PASSWORD").ok()?, + )) + })(); + assert!( + details.is_some() || !caldav_required(), + "RUNWAY_REQUIRE_CALDAV is set but no server is configured", + ); + details +} + +// ------------------------------------------------------------------- basics -- + +#[tokio::test] +async fn health_needs_no_session() { + let harness = Harness::new().await; + + let reply = harness + .send(Request::get("/api/health").body(Body::empty()).unwrap()) + .await; + + assert_eq!(reply.status, StatusCode::OK); +} + +#[tokio::test] +async fn an_unauthenticated_request_is_rejected_with_a_code_not_prose() { + let harness = Harness::new().await; + + let reply = harness + .send( + Request::get("/api/auth/session") + .body(Body::empty()) + .unwrap(), + ) + .await; + + assert_eq!(reply.status, StatusCode::UNAUTHORIZED); + assert_eq!( + reply.code(), + Some("unauthenticated"), + "a client has to be able to branch on this; v1 returned \ + Result and the frontend could not tell a 401 from a parse \ + failure", + ); +} + +// ----------------------------------------------------------------- sessions -- + +#[tokio::test] +async fn a_session_identifies_its_user() { + let harness = Harness::new().await; + let (user, token) = harness.signed_in_user().await; + + let reply = harness.get_with_session("/api/auth/session", &token).await; + + assert_eq!(reply.status, StatusCode::OK); + assert_eq!( + reply.json.as_ref().unwrap()["user"]["username"], + json!("alex"), + ); + assert_eq!(reply.json.as_ref().unwrap()["user"]["id"], json!(user.id.0)); +} + +#[tokio::test] +async fn an_expired_session_is_not_accepted() { + let harness = Harness::with_lifetime(TimeDelta::seconds(-1)).await; + let (_, token) = harness.signed_in_user().await; + + let reply = harness.get_with_session("/api/auth/session", &token).await; + + assert_eq!(reply.status, StatusCode::UNAUTHORIZED); + assert_eq!(reply.code(), Some("unauthenticated")); +} + +#[tokio::test] +async fn a_forged_token_is_not_accepted() { + let harness = Harness::new().await; + let (_, real) = harness.signed_in_user().await; + + for forged in [ + real.chars().rev().collect::(), + format!("{real}x"), + real[..real.len() - 1].to_owned(), + String::new(), + "../../etc/passwd".to_owned(), + ] { + let reply = harness.get_with_session("/api/auth/session", &forged).await; + assert_eq!( + reply.status, + StatusCode::UNAUTHORIZED, + "token {forged:?} should not have been accepted", + ); + } +} + +#[tokio::test] +async fn logging_out_ends_the_session_on_the_server_not_just_in_the_browser() { + let harness = Harness::new().await; + let (_, token) = harness.signed_in_user().await; + + let reply = harness.post_with_session("/api/auth/logout", &token).await; + assert_eq!(reply.status, StatusCode::OK); + + let cookie = reply.session_cookie().expect("the cookie must be cleared"); + assert!(cookie.contains("Max-Age=0"), "{cookie}"); + + // The important half: the token itself must be dead, not merely forgotten + // by this browser. Anyone holding a copy has to be locked out too. + let after = harness.get_with_session("/api/auth/session", &token).await; + assert_eq!(after.status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn logging_out_without_a_session_is_not_an_error() { + let harness = Harness::new().await; + + let reply = harness + .send( + Request::post("/api/auth/logout") + .body(Body::empty()) + .unwrap(), + ) + .await; + + assert_eq!( + reply.status, + StatusCode::OK, + "signing out when already signed out is what the user asked for", + ); +} + +#[tokio::test] +async fn a_session_survives_being_used() { + let harness = Harness::new().await; + let (_, token) = harness.signed_in_user().await; + + for _ in 0..3 { + assert_eq!( + harness + .get_with_session("/api/auth/session", &token) + .await + .status, + StatusCode::OK, + ); + } +} + +#[tokio::test] +async fn one_users_session_does_not_reach_another_users_account() { + let harness = Harness::new().await; + let (alex, alex_token) = harness.signed_in_user().await; + let sam = harness + .state + .db + .users() + .record_login("sam", "https://dav.example.org/", None) + .await + .unwrap(); + let sam_token = harness.state.auth.begin_session(&sam, None).await.unwrap(); + + let reply = harness + .get_with_session("/api/auth/session", &sam_token) + .await; + + assert_eq!(reply.json.as_ref().unwrap()["user"]["id"], json!(sam.id.0)); + assert_ne!(alex.id, sam.id); + assert_ne!(alex_token, sam_token); +} + +// ------------------------------------------------------------------ secrets -- + +#[tokio::test] +async fn no_response_body_ever_contains_the_session_token() { + let harness = Harness::new().await; + let (_, token) = harness.signed_in_user().await; + + for reply in [ + harness.get_with_session("/api/auth/session", &token).await, + harness.post_with_session("/api/auth/logout", &token).await, + ] { + assert!( + !reply.text.contains(&token), + "the token belongs in an HttpOnly cookie and nowhere else -- a copy \ + in the body is readable by any script on the page:\n{}", + reply.text, + ); + } +} + +#[tokio::test] +async fn the_session_cookie_cannot_be_read_by_script() { + let harness = Harness::new().await; + let (user, _) = harness.signed_in_user().await; + let token = harness.state.auth.begin_session(&user, None).await.unwrap(); + + let cookie = runway_server::auth::session_cookie(token, TimeDelta::hours(1), true); + let rendered = cookie.to_string(); + + assert!(rendered.contains("HttpOnly"), "{rendered}"); + assert!(rendered.contains("Secure"), "{rendered}"); + assert!(rendered.contains("SameSite=Lax"), "{rendered}"); + assert!(rendered.contains("Path=/"), "{rendered}"); +} + +#[tokio::test] +async fn an_internal_error_does_not_describe_the_inside_of_the_server() { + // Force one: close the pool, then make a request that has to touch it. + let harness = Harness::new().await; + let (_, token) = harness.signed_in_user().await; + harness.state.db.close().await; + + let reply = harness.get_with_session("/api/auth/session", &token).await; + + assert_eq!(reply.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(reply.code(), Some("internal")); + assert!( + !reply.text.contains("sqlx") && !reply.text.contains("sqlite"), + "the detail belongs in the log, not in the response:\n{}", + reply.text, + ); +} + +// ------------------------------------------------------------------- crypto -- + +#[tokio::test] +async fn a_stored_credential_is_not_readable_without_the_key() { + let harness = Harness::new().await; + let (user, _) = harness.signed_in_user().await; + let key = SecretKey::generate().unwrap(); + let (ciphertext, nonce) = key.encrypt(b"hunter2").unwrap(); + + harness + .state + .db + .credentials() + .store( + &user.id, + &ciphertext, + &nonce, + runway_server::auth::ALGORITHM, + ) + .await + .unwrap(); + + let stored = harness + .state + .db + .credentials() + .load(&user.id) + .await + .unwrap() + .unwrap(); + + assert!( + !String::from_utf8_lossy(&stored.ciphertext).contains("hunter2"), + "the password must not be recoverable by reading the row", + ); + assert_eq!( + key.decrypt(&stored.ciphertext, &stored.nonce).unwrap(), + b"hunter2" + ); + + let other = SecretKey::generate().unwrap(); + assert!( + other.decrypt(&stored.ciphertext, &stored.nonce).is_err(), + "and not by anyone with a different key", + ); +} + +#[tokio::test] +async fn a_tampered_credential_fails_rather_than_decrypting_to_nonsense() { + let key = SecretKey::generate().unwrap(); + let (mut ciphertext, nonce) = key.encrypt(b"hunter2").unwrap(); + ciphertext[0] ^= 0x01; + + assert!( + key.decrypt(&ciphertext, &nonce).is_err(), + "the AEAD tag must be checked; a silently wrong password would show up \ + as a mysterious CalDAV rejection later", + ); +} + +#[tokio::test] +async fn the_same_password_encrypts_differently_every_time() { + let key = SecretKey::generate().unwrap(); + + let (first, first_nonce) = key.encrypt(b"hunter2").unwrap(); + let (second, second_nonce) = key.encrypt(b"hunter2").unwrap(); + + assert_ne!(first, second, "a fresh nonce per encryption"); + assert_ne!(first_nonce, second_nonce); +} + +#[tokio::test] +async fn a_key_round_trips_through_its_configured_form() { + let key = SecretKey::generate().unwrap(); + let text = key.expose_for_config(); + + let reloaded = SecretKey::parse(&text).unwrap(); + let (ciphertext, nonce) = key.encrypt(b"secret").unwrap(); + + assert_eq!(reloaded.decrypt(&ciphertext, &nonce).unwrap(), b"secret"); +} + +#[tokio::test] +async fn a_key_is_accepted_as_hex_or_base64_and_refused_otherwise() { + let hex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; + assert!(SecretKey::parse(hex).is_ok()); + assert!(SecretKey::parse("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8").is_ok()); + + assert!(SecretKey::parse("too-short").is_err()); + assert!(SecretKey::parse("").is_err()); +} + +#[tokio::test] +async fn a_secret_key_does_not_print_itself() { + let key = SecretKey::generate().unwrap(); + + let rendered = format!("{key:?}"); + + assert_eq!(rendered, "SecretKey()"); + assert!(!rendered.contains(&key.expose_for_config())); +} + +// ------------------------------------------------------- against a real server + +#[tokio::test] +async fn logging_in_against_a_real_caldav_server_starts_a_session() { + let Some((url, user, password)) = caldav() else { + eprintln!("SKIPPED: no CalDAV server configured"); + return; + }; + let harness = Harness::new().await; + + let reply = harness + .post( + "/api/auth/login", + json!({ "server_url": url, "username": user, "password": password }), + ) + .await; + + assert_eq!(reply.status, StatusCode::OK, "{}", reply.text); + assert_eq!( + reply.json.as_ref().unwrap()["user"]["username"], + json!(user) + ); + + let token = reply.token(); + let cookie = reply.session_cookie().unwrap(); + assert!(cookie.contains("HttpOnly"), "{cookie}"); + + // The session works, which is the only proof that matters. + assert_eq!( + harness + .get_with_session("/api/auth/session", &token) + .await + .status, + StatusCode::OK, + ); +} + +#[tokio::test] +async fn a_login_response_never_contains_the_password() { + let Some((url, user, password)) = caldav() else { + return; + }; + let harness = Harness::new().await; + + let reply = harness + .post( + "/api/auth/login", + json!({ "server_url": url, "username": user, "password": &password }), + ) + .await; + + assert!( + !reply.text.contains(&password), + "the password must never come back out:\n{}", + reply.text, + ); + assert!( + !reply.text.contains(&reply.token()), + "and neither must the session token", + ); + for cookie in &reply.cookies { + assert!(!cookie.contains(&password), "nor in a cookie: {cookie}"); + } +} + +#[tokio::test] +async fn a_wrong_password_is_reported_as_such() { + let Some((url, user, _)) = caldav() else { + return; + }; + let harness = Harness::new().await; + + let reply = harness + .post( + "/api/auth/login", + json!({ "server_url": url, "username": user, "password": "not-the-password" }), + ) + .await; + + assert_eq!(reply.status, StatusCode::UNAUTHORIZED); + assert_eq!(reply.code(), Some("invalid_credentials")); + assert!( + reply.session_cookie().is_none(), + "and no session is started" + ); +} + +#[tokio::test] +async fn an_unreachable_server_is_not_reported_as_a_wrong_password() { + let harness = Harness::new().await; + + let reply = harness + .post( + "/api/auth/login", + json!({ + "server_url": "http://127.0.0.1:1/dav/", + "username": "alex", + "password": "whatever", + }), + ) + .await; + + assert_eq!( + reply.code(), + Some("upstream"), + "telling somebody their password is wrong when the server is down sends \ + them to reset a password that was fine", + ); +} + +#[tokio::test] +async fn an_empty_username_or_password_is_refused_before_any_request() { + let harness = Harness::new().await; + + for body in [ + json!({ "server_url": "https://example.org/dav/", "username": "", "password": "x" }), + json!({ "server_url": "https://example.org/dav/", "username": "alex", "password": "" }), + ] { + let reply = harness.post("/api/auth/login", body).await; + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + assert_eq!(reply.code(), Some("bad_request")); + } +} + +#[tokio::test] +async fn logging_in_stores_a_credential_that_can_reach_the_server_again() { + let Some((url, user, password)) = caldav() else { + return; + }; + let harness = Harness::new().await; + + harness + .post( + "/api/auth/login", + json!({ "server_url": url, "username": user, "password": password }), + ) + .await; + + let stored = harness + .state + .db + .users() + .find_by_login(&user, &url) + .await + .unwrap() + .unwrap(); + + // The whole reason the password is kept: every later request is made on the + // user's behalf, and this is the only path back to a usable client. + let client = harness.state.auth.caldav_for(&stored).await.unwrap(); + assert!(client.current_user_principal().await.is_ok()); +} + +#[tokio::test] +async fn forgetting_a_user_revokes_their_sessions_and_their_credential() { + let Some((url, user, password)) = caldav() else { + return; + }; + let harness = Harness::new().await; + let reply = harness + .post( + "/api/auth/login", + json!({ "server_url": url, "username": user, "password": password }), + ) + .await; + let token = reply.token(); + + let stored = harness + .state + .db + .users() + .find_by_login(&user, &url) + .await + .unwrap() + .unwrap(); + harness.state.auth.forget(&stored).await.unwrap(); + + assert_eq!( + harness + .get_with_session("/api/auth/session", &token) + .await + .status, + StatusCode::UNAUTHORIZED, + ); + assert!(harness.state.auth.caldav_for(&stored).await.is_err()); +}