use std::sync::Arc;
use chrono::Duration;
use sqlx::SqlitePool;
use crate::db::Db;
use crate::error::AuthError;
use crate::sessions::{self, SessionConfig};
use crate::types::{SessionToken, User};
pub struct LoginOutcome {
pub user: User,
pub token: SessionToken,
pub set_cookie: String,
}
#[derive(Debug, thiserror::Error)]
pub enum BuildError {
#[error("database error: {0}")]
Database(#[from] AuthError),
#[error("invalid configuration: {0}")]
InvalidConfig(&'static str),
}
enum PoolSource {
Url(String),
Pool(SqlitePool),
}
pub struct AllowThemBuilder {
pool_source: PoolSource,
session_ttl: Option<Duration>,
cookie_name: Option<&'static str>,
cookie_secure: Option<bool>,
cookie_domain: String,
mfa_key: Option<[u8; 32]>,
signing_key: Option<[u8; 32]>,
csrf_key: Option<[u8; 32]>,
base_url: Option<String>,
}
impl AllowThemBuilder {
pub fn new(url: impl Into<String>) -> Self {
Self {
pool_source: PoolSource::Url(url.into()),
session_ttl: None,
cookie_name: None,
cookie_secure: None,
cookie_domain: String::new(),
mfa_key: None,
signing_key: None,
csrf_key: None,
base_url: None,
}
}
pub fn with_pool(pool: SqlitePool) -> Self {
Self {
pool_source: PoolSource::Pool(pool),
session_ttl: None,
cookie_name: None,
cookie_secure: None,
cookie_domain: String::new(),
mfa_key: None,
signing_key: None,
csrf_key: None,
base_url: None,
}
}
pub fn session_ttl(mut self, ttl: Duration) -> Self {
self.session_ttl = Some(ttl);
self
}
pub fn cookie_name(mut self, name: &'static str) -> Self {
self.cookie_name = Some(name);
self
}
pub fn cookie_secure(mut self, secure: bool) -> Self {
self.cookie_secure = Some(secure);
self
}
pub fn cookie_domain(mut self, domain: impl Into<String>) -> Self {
self.cookie_domain = domain.into();
self
}
pub fn mfa_key(mut self, key: [u8; 32]) -> Self {
self.mfa_key = Some(key);
self
}
pub fn signing_key(mut self, key: [u8; 32]) -> Self {
self.signing_key = Some(key);
self
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
pub fn csrf_key(mut self, key: [u8; 32]) -> Self {
self.csrf_key = Some(key);
self
}
pub async fn build(self) -> Result<AllowThem, BuildError> {
let db = match self.pool_source {
PoolSource::Url(url) => Db::connect(&url).await?,
PoolSource::Pool(pool) => Db::new(pool).await?,
};
let defaults = SessionConfig::default();
let session_config = SessionConfig {
ttl: self.session_ttl.unwrap_or(defaults.ttl),
cookie_name: self.cookie_name.unwrap_or(defaults.cookie_name),
secure: self.cookie_secure.unwrap_or(defaults.secure),
};
Ok(AllowThem {
inner: Arc::new(Inner {
db,
session_config,
cookie_domain: self.cookie_domain,
mfa_key: self.mfa_key,
signing_key: self.signing_key,
csrf_key: self.csrf_key,
base_url: self.base_url,
}),
})
}
}
struct Inner {
db: Db,
session_config: SessionConfig,
cookie_domain: String,
mfa_key: Option<[u8; 32]>,
signing_key: Option<[u8; 32]>,
csrf_key: Option<[u8; 32]>,
base_url: Option<String>,
}
#[derive(Clone)]
pub struct AllowThem {
inner: Arc<Inner>,
}
impl AllowThem {
pub fn db(&self) -> &Db {
&self.inner.db
}
pub fn session_config(&self) -> &SessionConfig {
&self.inner.session_config
}
pub fn session_cookie(&self, token: &SessionToken) -> String {
sessions::session_cookie(token, &self.inner.session_config, &self.inner.cookie_domain)
}
pub(crate) fn mfa_key(&self) -> Result<&[u8; 32], AuthError> {
self.inner
.mfa_key
.as_ref()
.ok_or(AuthError::MfaNotConfigured)
}
pub(crate) fn signing_key(&self) -> Result<&[u8; 32], AuthError> {
self.inner
.signing_key
.as_ref()
.ok_or(AuthError::SigningKeyNotConfigured)
}
pub fn base_url(&self) -> Result<&str, AuthError> {
self.inner
.base_url
.as_deref()
.ok_or(AuthError::BaseUrlNotConfigured)
}
pub fn csrf_key(&self) -> Result<&[u8; 32], AuthError> {
self.inner
.csrf_key
.as_ref()
.ok_or(AuthError::CsrfKeyNotConfigured)
}
pub async fn get_decrypted_signing_key(
&self,
) -> Result<(crate::signing_keys::SigningKey, String), AuthError> {
let enc_key = self.signing_key()?;
let key = self.db().get_active_signing_key().await?;
let pem = crate::signing_keys::decrypt_private_key(&key, enc_key)?;
Ok((key, pem))
}
pub fn clear_session_cookie(&self) -> String {
sessions::clear_session_cookie(&self.inner.session_config, &self.inner.cookie_domain)
}
pub fn parse_session_cookie(&self, cookie_header: &str) -> Option<SessionToken> {
sessions::parse_session_cookie(cookie_header, self.inner.session_config.cookie_name)
}
pub async fn login(&self, identifier: &str, password: &str) -> Result<LoginOutcome, AuthError> {
use chrono::Utc;
use crate::audit::AuditEvent;
use crate::password::verify_password;
let user = self
.db()
.find_for_login(identifier)
.await
.map_err(|e| match e {
AuthError::NotFound => AuthError::InvalidCredentials,
other => other,
})?;
if !user.is_active {
return Err(AuthError::InvalidCredentials);
}
let hash = user
.password_hash
.as_ref()
.ok_or(AuthError::InvalidCredentials)?;
if !verify_password(password, hash)? {
return Err(AuthError::InvalidCredentials);
}
let token = sessions::generate_token();
let token_hash = sessions::hash_token(&token);
let expires_at = Utc::now() + self.inner.session_config.ttl;
self.db()
.create_session(user.id, token_hash, None, None, expires_at)
.await?;
let _ = self
.db()
.log_audit(AuditEvent::Login, Some(&user.id), None, None, None, None)
.await;
let set_cookie = self.session_cookie(&token);
Ok(LoginOutcome {
user,
token,
set_cookie,
})
}
pub async fn create_session_cookie(
&self,
user_id: crate::types::UserId,
) -> Result<LoginOutcome, AuthError> {
use chrono::Utc;
let user = self.db().get_user(user_id).await?;
let token = sessions::generate_token();
let token_hash = sessions::hash_token(&token);
let expires_at = Utc::now() + self.inner.session_config.ttl;
self.db()
.create_session(user_id, token_hash, None, None, expires_at)
.await?;
let set_cookie = self.session_cookie(&token);
Ok(LoginOutcome {
user,
token,
set_cookie,
})
}
}
#[cfg(test)]
mod tests {
use sqlx::sqlite::SqliteConnectOptions;
use std::str::FromStr;
use super::*;
use crate::sessions::generate_token;
use crate::types::Email;
#[tokio::test]
async fn build_with_url_defaults() {
let ath = AllowThemBuilder::new("sqlite::memory:")
.build()
.await
.unwrap();
let config = ath.session_config();
assert_eq!(config.ttl, Duration::hours(24));
assert_eq!(config.cookie_name, "allowthem_session");
assert!(config.secure);
let token = generate_token();
let cookie = ath.session_cookie(&token);
assert!(!cookie.contains("; Domain="));
}
#[tokio::test]
async fn build_with_pool() {
let opts = SqliteConnectOptions::from_str("sqlite::memory:")
.unwrap()
.pragma("foreign_keys", "ON");
let pool = sqlx::SqlitePool::connect_with(opts).await.unwrap();
let ath = AllowThemBuilder::with_pool(pool).build().await.unwrap();
let email = Email::new("test@example.com".into()).unwrap();
let user = ath.db().create_user(email, "password123", None, None).await;
assert!(user.is_ok());
}
#[tokio::test]
async fn build_with_overrides() {
let ath = AllowThemBuilder::new("sqlite::memory:")
.session_ttl(Duration::hours(48))
.cookie_name("my_session")
.cookie_secure(false)
.cookie_domain("example.com")
.build()
.await
.unwrap();
let config = ath.session_config();
assert_eq!(config.ttl, Duration::hours(48));
assert_eq!(config.cookie_name, "my_session");
assert!(!config.secure);
}
#[tokio::test]
async fn session_cookie_uses_config() {
let ath = AllowThemBuilder::new("sqlite::memory:")
.cookie_name("custom")
.cookie_secure(false)
.cookie_domain("example.com")
.build()
.await
.unwrap();
let token = generate_token();
let cookie = ath.session_cookie(&token);
assert!(cookie.contains("custom="));
assert!(cookie.contains("; Domain=example.com"));
assert!(!cookie.contains("; Secure"));
}
#[tokio::test]
async fn clear_session_cookie_defaults() {
let ath = AllowThemBuilder::new("sqlite::memory:")
.build()
.await
.unwrap();
let cookie = ath.clear_session_cookie();
assert!(cookie.starts_with("allowthem_session=;"));
assert!(cookie.contains("; Max-Age=0"));
assert!(!cookie.contains("; Domain="));
assert!(cookie.contains("; Secure"));
}
#[tokio::test]
async fn clear_session_cookie_name_matches_session_cookie() {
let ath = AllowThemBuilder::new("sqlite::memory:")
.cookie_name("app_session")
.build()
.await
.unwrap();
let token = generate_token();
let set = ath.session_cookie(&token);
let clear = ath.clear_session_cookie();
assert!(set.starts_with("app_session="));
assert!(clear.starts_with("app_session=;"));
assert!(clear.contains("; Path=/"));
assert!(clear.contains("; Max-Age=0"));
}
#[tokio::test]
async fn clear_session_cookie_with_domain_and_no_secure() {
let ath = AllowThemBuilder::new("sqlite::memory:")
.cookie_name("my_session")
.cookie_secure(false)
.cookie_domain("example.com")
.build()
.await
.unwrap();
let cookie = ath.clear_session_cookie();
assert!(cookie.starts_with("my_session=;"));
assert!(cookie.contains("; Max-Age=0"));
assert!(cookie.contains("; Domain=example.com"));
assert!(!cookie.contains("; Secure"));
}
#[tokio::test]
async fn parse_session_cookie_uses_config() {
let ath = AllowThemBuilder::new("sqlite::memory:")
.cookie_name("custom")
.build()
.await
.unwrap();
let header = "custom=abc123; other=xyz";
let result = ath.parse_session_cookie(header);
assert!(result.is_some());
assert_eq!(result.unwrap().as_str(), "abc123");
}
#[tokio::test]
async fn build_with_bad_url_fails() {
let result = AllowThemBuilder::new("not-a-url").build().await;
assert!(result.is_err());
assert!(matches!(result.err().unwrap(), BuildError::Database(_)));
}
#[tokio::test]
async fn clone_shares_state() {
let ath = AllowThemBuilder::new("sqlite::memory:")
.build()
.await
.unwrap();
let ath2 = ath.clone();
let email = Email::new("shared@example.com".into()).unwrap();
let user = ath
.db()
.create_user(email, "password123", None, None)
.await
.unwrap();
let found = ath2.db().get_user(user.id).await;
assert!(found.is_ok());
assert_eq!(found.unwrap().id, user.id);
}
#[tokio::test]
async fn signing_key_not_configured_returns_error() {
let ath = AllowThemBuilder::new("sqlite::memory:")
.build()
.await
.unwrap();
let result = ath.signing_key();
assert!(matches!(
result,
Err(crate::error::AuthError::SigningKeyNotConfigured)
));
}
#[tokio::test]
async fn base_url_not_configured_returns_error() {
let ath = AllowThemBuilder::new("sqlite::memory:")
.build()
.await
.unwrap();
let result = ath.base_url();
assert!(matches!(
result,
Err(crate::error::AuthError::BaseUrlNotConfigured)
));
}
#[tokio::test]
async fn base_url_configured_returns_value() {
let ath = AllowThemBuilder::new("sqlite::memory:")
.base_url("https://auth.example.com")
.build()
.await
.unwrap();
let result = ath.base_url();
assert!(matches!(result, Ok("https://auth.example.com")));
}
#[tokio::test]
async fn login_success() {
let ath = AllowThemBuilder::new("sqlite::memory:")
.cookie_secure(false)
.build()
.await
.unwrap();
let email = Email::new("login@example.com".into()).unwrap();
ath.db()
.create_user(email, "secret", None, None)
.await
.unwrap();
let outcome = ath.login("login@example.com", "secret").await.unwrap();
assert_eq!(outcome.user.email.as_str(), "login@example.com");
assert!(!outcome.token.as_str().is_empty());
assert!(outcome.set_cookie.contains("allowthem_session="));
}
#[tokio::test]
async fn login_wrong_password() {
let ath = AllowThemBuilder::new("sqlite::memory:")
.build()
.await
.unwrap();
let email = Email::new("wp@example.com".into()).unwrap();
ath.db()
.create_user(email, "correct", None, None)
.await
.unwrap();
let result = ath.login("wp@example.com", "wrong").await;
assert!(matches!(result, Err(AuthError::InvalidCredentials)));
}
#[tokio::test]
async fn login_unknown_identifier() {
let ath = AllowThemBuilder::new("sqlite::memory:")
.build()
.await
.unwrap();
let result = ath.login("nobody@example.com", "any").await;
assert!(matches!(result, Err(AuthError::InvalidCredentials)));
}
#[tokio::test]
async fn login_inactive_user() {
let ath = AllowThemBuilder::new("sqlite::memory:")
.build()
.await
.unwrap();
let email = Email::new("inactive@example.com".into()).unwrap();
let user = ath
.db()
.create_user(email, "secret", None, None)
.await
.unwrap();
ath.db().update_user_active(user.id, false).await.unwrap();
let result = ath.login("inactive@example.com", "secret").await;
assert!(matches!(result, Err(AuthError::InvalidCredentials)));
}
#[tokio::test]
async fn login_no_password_hash() {
use crate::types::UserId;
let ath = AllowThemBuilder::new("sqlite::memory:")
.build()
.await
.unwrap();
let id = UserId::new();
let now = chrono::Utc::now()
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string();
sqlx::query(
"INSERT INTO allowthem_users \
(id, email, username, password_hash, email_verified, is_active, created_at, updated_at) \
VALUES (?, 'sso@example.com', NULL, NULL, 1, 1, ?, ?)",
)
.bind(id)
.bind(&now)
.bind(&now)
.execute(ath.db().pool())
.await
.unwrap();
let result = ath.login("sso@example.com", "any").await;
assert!(matches!(result, Err(AuthError::InvalidCredentials)));
}
#[tokio::test]
async fn create_session_cookie_success() {
let ath = AllowThemBuilder::new("sqlite::memory:")
.cookie_secure(false)
.build()
.await
.unwrap();
let email = Email::new("sess@example.com".into()).unwrap();
let user = ath
.db()
.create_user(email, "secret", None, None)
.await
.unwrap();
let outcome = ath.create_session_cookie(user.id).await.unwrap();
assert_eq!(outcome.user.id, user.id);
assert!(!outcome.token.as_str().is_empty());
assert!(outcome.set_cookie.contains("allowthem_session="));
let session = ath.db().lookup_session(&outcome.token).await.unwrap();
assert!(session.is_some());
}
#[tokio::test]
async fn create_session_cookie_unknown_user() {
use crate::types::UserId;
let ath = AllowThemBuilder::new("sqlite::memory:")
.build()
.await
.unwrap();
let result = ath.create_session_cookie(UserId::new()).await;
assert!(matches!(result, Err(AuthError::NotFound)));
}
}