//! 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()); }