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
+681
View File
@@ -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<Body>) -> 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<String> = 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<String>,
json: Option<Value>,
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<T, String> 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::<String>(),
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(<redacted>)");
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());
}