AkurAI Build
Menu

Public repository / ca78cb498416735c5ac15f7224c3b9e44791e99c

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

Latest change c7bf22ef78ad08f1f59b04d08e88d86e4ca91815 - feat: add tenant-safe account graph by Ólafur Búi Ólafsson

pub mod idp_client;
pub mod session;

use crate::router_setup::{internal_error, json_response, CrmState};
use akurai_http::{Request, Response};
use akurai_json::Value;
use std::sync::{Arc, Mutex};

/// GET /auth/login — redirect to IDP /authorize
pub fn login_route(state: Arc<Mutex<CrmState>>) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
    let _ = state; // unused, kept for signature consistency
    Box::new(move |_req: &Request| {
        let config = match idp_client::Config::from_env() {
            Ok(config) => config,
            Err(error) => return internal_error(&error),
        };
        let state_token = format!(
            "crm_{}",
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap_or_default()
                .as_nanos()
        );

        let location = config.authorization_url(&state_token);

        let cookie =
            format!("crm_oauth_state={state_token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600");

        let mut resp = Response::ok();
        resp.status = 302;
        resp.reason = "Found".to_string();
        resp = resp.with_header("Location", &location);
        resp = resp.with_header("Set-Cookie", &cookie);
        resp
    })
}

/// GET /auth/callback — exchange code, create session, redirect to dashboard
pub fn callback_route(
    state: Arc<Mutex<CrmState>>,
) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
    Box::new(move |req: &Request| {
        let query = req.query.as_deref().unwrap_or("");
        let mut code = String::new();
        let mut returned_state = String::new();

        for pair in query.split('&') {
            if let Some((key, value)) = pair.split_once('=') {
                match key {
                    "code" => code = value.to_string(),
                    "state" => returned_state = value.to_string(),
                    _ => {}
                }
            }
        }

        if code.is_empty() || returned_state.is_empty() {
            return auth_redirect_error();
        }

        if let Err(error) = idp_client::Config::from_env() {
            return internal_error(&error);
        }

        match idp_client::exchange_code(&code, &returned_state) {
            Ok(userinfo) => {
                let session_id = {
                    let state_lock = match state.lock() {
                        Ok(s) => s,
                        Err(_) => return internal_error("lock poisoned"),
                    };
                    let mut db = match state_lock.db.lock() {
                        Ok(db) => db,
                        Err(_) => return internal_error("db lock poisoned"),
                    };
                    match session::create(
                        &mut db,
                        &userinfo.sub,
                        &userinfo.email,
                        &userinfo.tenant_id,
                    ) {
                        Ok(session_id) => session_id,
                        Err(error) => return internal_error(&error),
                    }
                };

                let cookie = format!(
                    "crm_session={session_id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400"
                );

                let mut resp = Response::ok();
                resp.status = 302;
                resp.reason = "Found".to_string();
                resp = resp.with_header("Location", "/dashboard.html");
                resp = resp.with_header("Set-Cookie", &cookie);
                resp
            }
            Err(_) => auth_redirect_error(),
        }
    })
}

/// GET /api/me — return current user info from session
pub fn me_route(state: Arc<Mutex<CrmState>>) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
    Box::new(move |req: &Request| {
        let session_id = session::extract_session_id(req);
        let user = match session_id {
            Some(ref sid) => {
                let state_lock = match state.lock() {
                    Ok(s) => s,
                    Err(_) => return internal_error("lock poisoned"),
                };
                let mut db = match state_lock.db.lock() {
                    Ok(db) => db,
                    Err(_) => return internal_error("db lock poisoned"),
                };
                session::get_user(&mut db, sid)
            }
            None => None,
        };

        match user {
            Some(u) => json_response(Value::Object(vec![
                ("authenticated".into(), Value::Bool(true)),
                ("sub".into(), Value::Str(u.sub)),
                ("email".into(), Value::Str(u.email)),
                ("tenantId".into(), Value::Str(u.tenant_id)),
            ])),
            None => json_response(Value::Object(vec![(
                "authenticated".into(),
                Value::Bool(false),
            )])),
        }
    })
}

/// GET /auth/logout — delete session and clear cookie
pub fn logout_route(
    state: Arc<Mutex<CrmState>>,
) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
    Box::new(move |req: &Request| {
        if let Some(sid) = session::extract_session_id(req) {
            let state_lock = match state.lock() {
                Ok(lock) => lock,
                Err(_) => return internal_error("lock poisoned"),
            };
            let mut db = match state_lock.db.lock() {
                Ok(db) => db,
                Err(_) => return internal_error("db lock poisoned"),
            };
            let key = format!("_session:{sid}");
            if let Err(error) = db.delete(key.as_bytes()) {
                return internal_error(&format!("session delete: {error}"));
            }
            if let Err(error) = db.commit() {
                return internal_error(&format!("session commit: {error}"));
            }
        }

        let cookie = "crm_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0".to_string();

        let mut resp = Response::ok();
        resp.status = 302;
        resp.reason = "Found".to_string();
        resp = resp.with_header("Location", "/");
        resp = resp.with_header("Set-Cookie", &cookie);
        resp
    })
}

fn auth_redirect_error() -> Response {
    let mut resp = Response::ok();
    resp.status = 302;
    resp.reason = "Found".to_string();
    resp = resp.with_header("Location", "/?auth_error=1");
    resp
}