Something went wrong. Try again.
An OIDC provider for AT Protocol OAuth. Rust port of apenwarr/atlogin. forked from quilling.dev/ratlogin
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221use std::fs;use std::os::unix::fs::OpenOptionsExt;use std::path::Path;use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use rand::RngCore;use rusqlite::Connection;use sha2::{Digest, Sha256};
pub const FILE_NAME: &str = "ratlogin.sqlite3";pub const SCHEMA_VERSION: i64 = 2;
const SCHEMA_V1: &str = "CREATE TABLE flows ( state TEXT PRIMARY KEY, status INTEGER NOT NULL, created_at INTEGER NOT NULL, data TEXT NOT NULL);CREATE TABLE codes ( digest TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, data TEXT NOT NULL);CREATE TABLE tokens ( digest TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, data TEXT NOT NULL);CREATE TABLE sessions ( digest TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL, revoked_at INTEGER);CREATE TABLE clients ( id TEXT PRIMARY KEY, secret_digest TEXT NOT NULL, redirect_uris TEXT NOT NULL, email_policy TEXT NOT NULL DEFAULT 'none', signing_alg TEXT NOT NULL DEFAULT 'RS256', created_at INTEGER NOT NULL, revoked_at INTEGER);CREATE TABLE oauth_requests ( state TEXT PRIMARY KEY, created_at INTEGER NOT NULL, data TEXT NOT NULL);CREATE TABLE oauth_sessions ( did TEXT NOT NULL, session_id TEXT NOT NULL, data TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (did, session_id));";
const SCHEMA_V2: &str = "ALTER TABLE clients ADD COLUMN allow_list TEXT;ALTER TABLE clients ADD COLUMN deny_list TEXT NOT NULL DEFAULT '[]';ALTER TABLE clients ADD COLUMN insecure_redirects INTEGER NOT NULL DEFAULT 0;";
#[derive(Clone)]pub struct Database { connection: Arc<Mutex<Connection>>,}
impl Database { pub fn open(path: &Path) -> Result<Self, DatabaseError> { match fs::OpenOptions::new() .write(true) .create_new(true) .mode(0o600) .open(path) { Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} Err(error) => return Err(error.into()), } let connection = Connection::open(path)?; connection.pragma_update(None, "journal_mode", "WAL")?; Self::prepare(connection) }
pub fn in_memory() -> Result<Self, DatabaseError> { Self::prepare(Connection::open_in_memory()?) }
fn prepare(connection: Connection) -> Result<Self, DatabaseError> { connection.busy_timeout(std::time::Duration::from_secs(5))?; connection.pragma_update(None, "foreign_keys", "ON")?; let version: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?; if version > SCHEMA_VERSION { return Err(DatabaseError::Newer(version)); } if version < 1 { connection.execute_batch(SCHEMA_V1)?; } if version < 2 { connection.execute_batch(SCHEMA_V2)?; } if version < SCHEMA_VERSION { connection.pragma_update(None, "user_version", SCHEMA_VERSION)?; } Ok(Self { connection: Arc::new(Mutex::new(connection)), }) }
pub fn lock(&self) -> MutexGuard<'_, Connection> { self.connection .lock() .unwrap_or_else(PoisonError::into_inner) }}
pub fn digest(value: &str) -> String { Sha256::digest(value.as_bytes()) .iter() .map(|byte| format!("{byte:02x}")) .collect()}
pub fn random_token() -> String { let mut bytes = [0u8; 32]; rand::rngs::OsRng.fill_bytes(&mut bytes); bytes.iter().map(|byte| format!("{byte:02x}")).collect()}
#[derive(Debug, thiserror::Error)]pub enum DatabaseError { #[error("database: {0}")] Sqlite(String), #[error("database file: {0}")] Io(String), #[error("database schema version {0} is newer than this build supports ({SCHEMA_VERSION})")] Newer(i64), #[error("database record: {0}")] Encoding(String),}
impl From<rusqlite::Error> for DatabaseError { fn from(error: rusqlite::Error) -> Self { Self::Sqlite(error.to_string()) }}
impl From<std::io::Error> for DatabaseError { fn from(error: std::io::Error) -> Self { Self::Io(error.to_string()) }}
impl From<serde_json::Error> for DatabaseError { fn from(error: serde_json::Error) -> Self { Self::Encoding(error.to_string()) }}
#[cfg(test)]mod tests { use super::*;
fn version(db: &Database) -> i64 { db.lock() .query_row("PRAGMA user_version", [], |row| row.get(0)) .expect("version") }
#[test] fn a_fresh_database_is_private_stamped_and_refuses_newer_schemas() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join(FILE_NAME); let db = Database::open(&path).expect("create"); let mode = std::os::unix::fs::MetadataExt::mode(&fs::metadata(&path).expect("metadata")); assert_eq!(mode & 0o777, 0o600); assert_eq!(version(&db), SCHEMA_VERSION); drop(db); let db = Database::open(&path).expect("reopen"); assert_eq!(version(&db), SCHEMA_VERSION); db.lock() .pragma_update(None, "user_version", 99) .expect("bump version"); drop(db); assert!(matches!( Database::open(&path), Err(DatabaseError::Newer(99)) )); assert!(matches!( Database::open(&dir.path().join("absent").join(FILE_NAME)), Err(DatabaseError::Io(_)) )); }
#[test] fn a_version_one_database_gains_the_new_client_columns() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join(FILE_NAME); let db = Database::open(&path).expect("create"); db.lock() .execute_batch( "ALTER TABLE clients DROP COLUMN allow_list; ALTER TABLE clients DROP COLUMN deny_list; ALTER TABLE clients DROP COLUMN insecure_redirects; PRAGMA user_version = 1;", ) .expect("downgrade"); drop(db); let db = Database::open(&path).expect("migrate"); assert_eq!(version(&db), SCHEMA_VERSION); db.lock() .execute( "SELECT allow_list, deny_list, insecure_redirects FROM clients", [], ) .expect("columns"); }}