From 285f863106efdd692e042ccc10b5df96b81ba66f Mon Sep 17 00:00:00 2001 From: Chris van der Loo Date: Sat, 5 Sep 2026 14:01:38 -0400 Subject: [PATCH] feat: persist expiring OAuth and browser secrets --- packages/server/src/auth/browser.rs | 61 ++++++++++++ packages/server/src/storage/mod.rs | 137 +++++++++++++++++++++++++++ packages/server/src/storage/oauth.rs | 59 ++++++++++++ 3 files changed, 257 insertions(+) create mode 100644 packages/server/src/auth/browser.rs create mode 100644 packages/server/src/storage/mod.rs create mode 100644 packages/server/src/storage/oauth.rs diff --git a/packages/server/src/auth/browser.rs b/packages/server/src/auth/browser.rs new file mode 100644 index 0000000..8b8c5b8 --- /dev/null +++ b/packages/server/src/auth/browser.rs @@ -0,0 +1,61 @@ +use crate::{config::Config, storage::Database}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +pub const COOKIE: &str = "crate_session"; +pub const LOGIN_COOKIE: &str = "crate_login"; +const LIFETIME: i64 = 60 * 60 * 24 * 30; + +#[derive(Clone, Serialize, Deserialize)] +pub struct BrowserSession { + pub did: String, +} + +pub fn create(database: &Database, did: String) -> anyhow::Result { + let token = token(); + database.set("browser", &hash(&token), &BrowserSession { did }, LIFETIME)?; + Ok(token) +} + +pub fn get(database: &Database, cookie: Option<&str>) -> anyhow::Result> { + match cookie { + Some(token) => database.get("browser", &hash(token)), + None => Ok(None), + } +} + +pub fn delete(database: &Database, cookie: &str) -> anyhow::Result<()> { + database.delete("browser", &hash(cookie)) +} + +pub fn cookie(config: &Config, name: &str, token: &str, max_age: i64) -> String { + format!( + "{name}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{}", + if config.secure() { "; Secure" } else { "" } + ) +} + +pub fn session_cookie(config: &Config, value: &str) -> String { + cookie(config, COOKIE, value, LIFETIME) +} + +pub fn read<'a>(request: &'a poem::Request, name: &str) -> Option<&'a str> { + request + .header("cookie")? + .split(';') + .filter_map(|part| part.trim().split_once('=')) + .find(|(key, _)| *key == name) + .map(|(_, value)| value) +} + +pub fn token() -> String { + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +fn hash(token: &str) -> String { + URL_SAFE_NO_PAD.encode(Sha256::digest(token.as_bytes())) +} diff --git a/packages/server/src/storage/mod.rs b/packages/server/src/storage/mod.rs new file mode 100644 index 0000000..4cb0cc4 --- /dev/null +++ b/packages/server/src/storage/mod.rs @@ -0,0 +1,137 @@ +pub mod oauth; + +use rusqlite::{Connection, OptionalExtension, params}; +use serde::{Serialize, de::DeserializeOwned}; +use std::{ + path::Path, + sync::{Arc, Mutex}, + time::{SystemTime, UNIX_EPOCH}, +}; + +#[derive(Clone)] +pub struct Database(Arc); + +struct Storage { + connection: Mutex, + _lock: std::fs::File, +} + +impl Database { + pub fn open(directory: &Path) -> anyhow::Result { + std::fs::create_dir_all(directory)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?; + } + let lock = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(directory.join("server.lock"))?; + lock.try_lock() + .map_err(|_| anyhow::anyhow!("Another Crate server is using this session directory"))?; + let path = directory.join("sessions.sqlite"); + let db = Connection::open(&path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + db.busy_timeout(std::time::Duration::from_secs(5))?; + db.execute_batch("PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS secrets (namespace TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, expires INTEGER NOT NULL, PRIMARY KEY(namespace,key));")?; + Ok(Self(Arc::new(Storage { + connection: Mutex::new(db), + _lock: lock, + }))) + } + + pub fn get( + &self, + namespace: &str, + key: &str, + ) -> anyhow::Result> { + let db = self + .0 + .connection + .lock() + .map_err(|_| anyhow::anyhow!("session database lock failed"))?; + let value: Option = db + .query_row( + "SELECT value FROM secrets WHERE namespace=? AND key=? AND expires>?", + params![namespace, key, now()], + |row| row.get(0), + ) + .optional()?; + value + .map(|v| serde_json::from_str(&v).map_err(Into::into)) + .transpose() + } + + pub fn set( + &self, + namespace: &str, + key: &str, + value: &T, + ttl: i64, + ) -> anyhow::Result<()> { + let db = self + .0 + .connection + .lock() + .map_err(|_| anyhow::anyhow!("session database lock failed"))?; + db.execute("DELETE FROM secrets WHERE expires<=?", [now()])?; + db.execute("INSERT INTO secrets(namespace,key,value,expires) VALUES(?,?,?,?) ON CONFLICT(namespace,key) DO UPDATE SET value=excluded.value, expires=excluded.expires", params![namespace,key,serde_json::to_string(value)?,now()+ttl])?; + Ok(()) + } + + pub fn delete(&self, namespace: &str, key: &str) -> anyhow::Result<()> { + self.0 + .connection + .lock() + .map_err(|_| anyhow::anyhow!("session database lock failed"))? + .execute( + "DELETE FROM secrets WHERE namespace=? AND key=?", + params![namespace, key], + )?; + Ok(()) + } + + pub fn clear(&self, namespace: &str) -> anyhow::Result<()> { + self.0 + .connection + .lock() + .map_err(|_| anyhow::anyhow!("session database lock failed"))? + .execute("DELETE FROM secrets WHERE namespace=?", [namespace])?; + Ok(()) + } +} + +pub fn now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn persists_isolates_and_expires_sessions() { + let dir = tempfile::tempdir().unwrap(); + let db = Database::open(dir.path()).unwrap(); + db.set("browser", "id", &"alice", 60).unwrap(); + assert!(db.get::("oauth", "id").unwrap().is_none()); + assert!(Database::open(dir.path()).is_err()); + drop(db); + let db = Database::open(dir.path()).unwrap(); + assert_eq!( + db.get::("browser", "id").unwrap().as_deref(), + Some("alice") + ); + db.set("browser", "expired", &"alice", -1).unwrap(); + assert!(db.get::("browser", "expired").unwrap().is_none()); + } +} diff --git a/packages/server/src/storage/oauth.rs b/packages/server/src/storage/oauth.rs new file mode 100644 index 0000000..042bf83 --- /dev/null +++ b/packages/server/src/storage/oauth.rs @@ -0,0 +1,59 @@ +use super::Database; +use atrium_common::store::Store; +use atrium_oauth::store::{ + session::{Session, SessionStore}, + state::{InternalStateData, StateStore}, +}; +use serde::{Serialize, de::DeserializeOwned}; +use std::{hash::Hash, marker::PhantomData}; + +#[derive(Clone)] +pub struct OAuthStore { + database: Database, + namespace: &'static str, + ttl: i64, + value: PhantomData, +} + +impl OAuthStore { + pub fn new(database: Database, namespace: &'static str, ttl: i64) -> Self { + Self { + database, + namespace, + ttl, + value: PhantomData, + } + } +} + +#[derive(Debug, thiserror::Error)] +#[error("OAuth persistence failed: {0}")] +pub struct StoreError(#[from] anyhow::Error); + +impl Store for OAuthStore +where + K: Eq + Hash + AsRef + Send + Sync, + V: Clone + Serialize + DeserializeOwned + Send + Sync, +{ + type Error = StoreError; + async fn get(&self, key: &K) -> Result, StoreError> { + Ok(self.database.get(self.namespace, key.as_ref())?) + } + async fn set(&self, key: K, value: V) -> Result<(), StoreError> { + Ok(self + .database + .set(self.namespace, key.as_ref(), &value, self.ttl)?) + } + async fn del(&self, key: &K) -> Result<(), StoreError> { + Ok(self.database.delete(self.namespace, key.as_ref())?) + } + async fn clear(&self) -> Result<(), StoreError> { + Ok(self.database.clear(self.namespace)?) + } +} + +impl StateStore for OAuthStore {} +impl SessionStore for OAuthStore {} + +pub type SessionPersistence = OAuthStore; +pub type StatePersistence = OAuthStore; -- 2.54.0