AkurAI Build
Menu

Public repository / main

AkurAI-CRM / crates/crm-api/src/auth/session.rs

Latest change 3ca078e63808e39f59571f8562e9a829f261ae13 - Restore CSRF guard, admin-gated merge, IdP logout and login redirects 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 role: Role,
}

/// Organization membership role from the IdP (`role` claim).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
    Owner,
    Admin,
    Member,
}

impl Role {
    /// Unknown or missing roles get the least privilege.
    pub fn parse(value: &str) -> Self {
        match value {
            "owner" => Self::Owner,
            "admin" => Self::Admin,
            _ => Self::Member,
        }
    }

    pub fn as_str(self) -> &'static str {
        match self {
            Self::Owner => "owner",
            Self::Admin => "admin",
            Self::Member => "member",
        }
    }

    pub fn is_admin(self) -> bool {
        matches!(self, Self::Owner | Self::Admin)
    }
}

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 const COOKIE_NAME: &str = "akurai_session";
/// Cookie name used by builds before the AkurAI IdP rollout; still read.
const LEGACY_COOKIE_NAME: &str = "crm_session";

fn key(session_id: &str) -> String {
    format!("_session:{session_id}")
}

pub fn create(db: &mut BTree, sub: &str, email: &str, tenant_id: &str) -> Result<String, String> {
    create_with_role(db, sub, email, tenant_id, Role::Member)
}

pub fn create_with_role(
    db: &mut BTree,
    sub: &str,
    email: &str,
    tenant_id: &str,
    role: Role,
) -> Result<String, String> {
    if sub.trim().is_empty() {
        return Err("sessions require an authenticated subject".to_string());
    }
    let tenant_id = tenant_id.trim();
    if tenant_id.is_empty() {
        return Err("sessions require an authenticated tenant".to_string());
    }
    let now = now_secs();
    let expires_at = now
        .checked_add(SESSION_TTL_SECS)
        .ok_or_else(|| "session expiry overflow".to_string())?;
    let session_id = super::pkce::random_token()?;

    let record = Value::Object(vec![
        ("sub".into(), Value::Str(sub.into())),
        ("email".into(), Value::Str(email.into())),
        ("tenantId".into(), Value::Str(tenant_id.into())),
        ("organizationId".into(), Value::Str(tenant_id.into())),
        ("role".into(), Value::Str(role.as_str().into())),
        ("createdAt".into(), Value::Int(now as i64)),
        ("expiresAt".into(), Value::Int(expires_at as i64)),
    ]);

    db.insert(key(&session_id).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 delete(db: &mut BTree, session_id: &str) -> Result<(), String> {
    db.delete(key(session_id).as_bytes())
        .map_err(|error| format!("session delete: {error}"))?;
    db.commit()
        .map_err(|error| format!("session commit: {error}"))
}

pub fn set_cookie(session_id: &str) -> String {
    format!(
        "{COOKIE_NAME}={session_id}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={SESSION_TTL_SECS}"
    )
}

pub fn clear_cookie() -> String {
    format!("{COOKIE_NAME}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0")
}

/// Value of a named cookie from the request's `Cookie` header.
pub fn cookie(req: &akurai_http::Request, name: &str) -> Option<String> {
    req.header("Cookie")?.split(';').find_map(|part| {
        let (key, value) = part.trim().split_once('=')?;
        let value = value.trim();
        (key == name && !value.is_empty()).then(|| value.to_string())
    })
}

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

    cookie(req, COOKIE_NAME).or_else(|| cookie(req, LEGACY_COOKIE_NAME))
}

pub fn get_user(db: &mut BTree, session_id: &str) -> Option<User> {
    match db.get(key(session_id).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();
                let role = Role::parse(val.get("role").and_then(|v| v.as_str()).unwrap_or(""));
                Some(User {
                    sub,
                    email,
                    tenant_id,
                    role,
                })
            } else {
                None
            }
        }
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn db(name: &str) -> BTree {
        crate::auth::test_db(&format!("session-{name}"))
    }

    fn request(head: &str) -> akurai_http::Request {
        akurai_http::Request::parse_head(format!("GET / HTTP/1.1\r\n{head}\r\n\r\n").as_bytes())
            .unwrap()
    }

    #[test]
    fn reads_akurai_session_cookie_and_legacy_name() {
        let req = request("Cookie: other=1; akurai_session=abc");
        assert_eq!(extract_session_id(&req).as_deref(), Some("abc"));
        let legacy = request("Cookie: crm_session=old");
        assert_eq!(extract_session_id(&legacy).as_deref(), Some("old"));
    }

    #[test]
    fn session_cookie_is_secure_http_only_lax() {
        assert_eq!(
            set_cookie("sid"),
            "akurai_session=sid; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=86400"
        );
        assert_eq!(
            clear_cookie(),
            "akurai_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"
        );
    }

    #[test]
    fn delete_removes_session() {
        let mut db = db("delete");
        let sid = create(&mut db, "sub", "e", "t").unwrap();
        delete(&mut db, &sid).unwrap();
        assert!(get_user(&mut db, &sid).is_none());
    }

    #[test]
    fn role_is_stored_and_defaults_to_member() {
        let mut db = db("role");
        let admin = create_with_role(&mut db, "sub", "e", "t", Role::Admin).unwrap();
        assert_eq!(get_user(&mut db, &admin).unwrap().role, Role::Admin);
        let plain = create(&mut db, "sub", "e", "t").unwrap();
        assert_eq!(get_user(&mut db, &plain).unwrap().role, Role::Member);
        assert_eq!(Role::parse("superuser"), Role::Member);
        assert!(Role::Owner.is_admin() && !Role::Member.is_admin());
    }

    #[test]
    fn sessions_require_subject() {
        assert_eq!(
            create(&mut db("nosub"), " ", "e", "t").unwrap_err(),
            "sessions require an authenticated subject"
        );
    }

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