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