Menu
Public repository / ca78cb498416735c5ac15f7224c3b9e44791e99c
AkurAI-CRM / crates/crm-api/src/auth/session.rs
Latest change c7bf22ef78ad08f1f59b04d08e88d86e4ca91815 - feat: add tenant-safe account graph by Ólafur Búi Ólafsson
use akurai_json::Value;
use akurai_storage::BTree;
pub struct User {
pub sub: String,
pub email: String,
pub tenant_id: String,
}
pub const SESSION_TTL_SECS: u64 = 86_400;
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub fn create(db: &mut BTree, sub: &str, email: &str, tenant_id: &str) -> Result<String, String> {
let tenant_id = tenant_id.trim();
if tenant_id.is_empty() {
return Err("sessions require an authenticated tenant".to_string());
}
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
let now = now_secs();
let expires_at = now
.checked_add(SESSION_TTL_SECS)
.ok_or_else(|| "session expiry overflow".to_string())?;
let rand = RandomState::new().build_hasher().finish();
let session_id = format!("s_{:x}_{}", rand, now);
let record = Value::Object(vec![
("sub".into(), Value::Str(sub.into())),
("email".into(), Value::Str(email.into())),
("tenantId".into(), Value::Str(tenant_id.into())),
("createdAt".into(), Value::Int(now as i64)),
("expiresAt".into(), Value::Int(expires_at as i64)),
]);
let key = format!("_session:{}", session_id);
db.insert(key.as_bytes(), record.to_json().as_bytes())
.map_err(|error| format!("session insert: {error}"))?;
db.commit()
.map_err(|error| format!("session commit: {error}"))?;
Ok(session_id)
}
pub fn extract_session_id(req: &akurai_http::Request) -> Option<String> {
if let Some(auth) = req.header("Authorization") {
if let Some(token) = auth.strip_prefix("Bearer ") {
let t = token.trim();
if !t.is_empty() {
return Some(t.to_string());
}
}
}
if let Some(cookies) = req.header("Cookie") {
for part in cookies.split(';') {
let part = part.trim();
if let Some(value) = part.strip_prefix("crm_session=") {
let v = value.trim();
if !v.is_empty() {
return Some(v.to_string());
}
}
}
}
None
}
pub fn get_user(db: &mut BTree, session_id: &str) -> Option<User> {
let key = format!("_session:{}", session_id);
match db.get(key.as_bytes()) {
Ok(Some(bytes)) => {
let s = String::from_utf8_lossy(&bytes);
if let Ok(val) = akurai_json::parse(&s) {
let _expires_at = val
.get("expiresAt")
.and_then(|value| value.as_i64())
.filter(|expires_at| *expires_at > now_secs() as i64)?;
let sub = val
.get("sub")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let email = val
.get("email")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let tenant_id = val
.get("tenantId")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())?
.to_string();
Some(User {
sub,
email,
tenant_id,
})
} else {
None
}
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn db(name: &str) -> BTree {
let path = format!("/tmp/akurai-crm-session-{name}-{}.db", std::process::id());
let _ = std::fs::remove_file(&path);
BTree::open(&path).unwrap()
}
#[test]
fn created_sessions_are_available_until_expiry() {
let mut db = db("active");
let session_id = create(&mut db, "sub", "user@example.test", "tenant-a").unwrap();
let user = get_user(&mut db, &session_id).unwrap();
assert_eq!(user.tenant_id, "tenant-a");
}
#[test]
fn expired_sessions_are_rejected() {
let mut db = db("expired");
let session_id = "expired-session";
let record = Value::Object(vec![
("sub".into(), Value::Str("sub".into())),
("email".into(), Value::Str("user@example.test".into())),
("tenantId".into(), Value::Str("tenant-a".into())),
("createdAt".into(), Value::Int((now_secs() - 2) as i64)),
("expiresAt".into(), Value::Int((now_secs() - 1) as i64)),
]);
db.insert(
format!("_session:{session_id}").as_bytes(),
record.to_json().as_bytes(),
)
.unwrap();
assert!(get_user(&mut db, session_id).is_none());
}
}