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:
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user