AkurAI Build
Menu

Public repository / c7bf22ef78ad08f1f59b04d08e88d86e4ca91815

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());
    }
}