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:
@@ -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 "$@"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
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<Self, KeyError> {
|
||||
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::<Result<Vec<u8>, _>>()
|
||||
.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<Self, CryptoError> {
|
||||
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<u8>, Vec<u8>), 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<Vec<u8>, 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<String, CryptoError> {
|
||||
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<const N: usize>() -> 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,
|
||||
}
|
||||
@@ -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<AppState> for CurrentUser {
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, AuthError> {
|
||||
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<Option<(User, Session)>, 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<CalDavClient, AuthError> {
|
||||
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),
|
||||
}
|
||||
@@ -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<Self, ConfigError> {
|
||||
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,
|
||||
}
|
||||
@@ -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<T, String>` 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<crate::db::DbError> for ApiError {
|
||||
fn from(error: crate::db::DbError) -> Self {
|
||||
Self::Internal(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::auth::AuthError> 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<runway_caldav::CalDavError> 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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<LoginRequest>,
|
||||
) -> Result<Response, ApiError> {
|
||||
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<AppState>, jar: CookieJar) -> Result<Response, ApiError> {
|
||||
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<Json<SessionResponse>, ApiError> {
|
||||
Ok(Json(SessionResponse { user }))
|
||||
}
|
||||
@@ -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<Value> {
|
||||
json!({ "status": "ok", "service": "runway", "version": env!("CARGO_PKG_VERSION") }).into()
|
||||
}
|
||||
@@ -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<Config>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub async fn new(config: Config) -> Result<Self, crate::db::DbError> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user