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
+6
View File
@@ -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
+22
View File
@@ -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)
}
+47
View File
@@ -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
}
+151
View File
@@ -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,
}
+45
View File
@@ -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())),
}
}
}
+196
View File
@@ -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),
}
+85
View File
@@ -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,
}
+119
View File
@@ -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()),
}
}
}
+10
View File
@@ -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;
+65 -2
View File
@@ -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");
}
+98
View File
@@ -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 }))
}
+12
View File
@@ -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()
}
+37
View File
@@ -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),
}
}
}
+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());
}