Menu
Public repository / 201b04d27b0773782f4a1a0558cee777ec8e3462
AkurAI-CRM / crates/crm-api/src/auth/session.rs
Latest change 201b04d27b0773782f4a1a0558cee777ec8e3462 - Recover deployed web login and MCP group gate 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 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_id_token(db, sub, email, tenant_id, "")
}
/// Persist a session; the ID token is kept as the RP-initiated logout hint.
pub fn create_with_id_token(
db: &mut BTree,
sub: &str,
email: &str,
tenant_id: &str,
id_token: &str,
) -> 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())),
("idToken".into(), Value::Str(id_token.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)
}
/// Delete a session, returning its stored ID token (empty when absent).
pub fn delete(db: &mut BTree, session_id: &str) -> Result<String, String> {
let key = key(session_id);
let id_token = db
.get(key.as_bytes())
.ok()
.flatten()
.and_then(|bytes| akurai_json::parse(&String::from_utf8_lossy(&bytes)).ok())
.and_then(|record| {
record
.get("idToken")
.and_then(Value::as_str)
.map(str::to_string)
})
.unwrap_or_default();
db.delete(key.as_bytes())
.map_err(|error| format!("session delete: {error}"))?;
db.commit()
.map_err(|error| format!("session commit: {error}"))?;
Ok(id_token)
}
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();
Some(User {
sub,
email,
tenant_id,
})
} 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_returns_id_token_and_removes_session() {
let mut db = db("delete");
let sid = create_with_id_token(&mut db, "sub", "e", "t", "idt").unwrap();
assert_eq!(delete(&mut db, &sid).unwrap(), "idt");
assert!(get_user(&mut db, &sid).is_none());
}
#[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());
}
}