AkurAI Build
Menu

Public repository / 228b5716ba0b7bcc87cce261ea6df891133a35bb

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

Latest change 3ca078e63808e39f59571f8562e9a829f261ae13 - Restore CSRF guard, admin-gated merge, IdP logout and login redirects by Ólafur Búi Ólafsson

//! Browser login against the AkurAI IdP: authorization code + PKCE (S256),
//! server-stored single-use state bound to an `akurai_oauth_state` cookie,
//! ID token checks, access-token introspection, `/userinfo` group gating,
//! and an `akurai_session` cookie. Logout ends the IdP session too.

pub mod csrf;
pub mod id_token;
pub mod idp_client;
pub mod login_state;
pub mod pkce;
pub mod session;

use crate::router_setup::{internal_error, json_response, CrmState};
use akurai_http::{Request, Response};
use akurai_json::Value;
use akurai_storage::BTree;
use idp_client::{urldecode, Config};
use login_state::LoginState;
use std::sync::{Arc, Mutex, MutexGuard};

pub const STATE_COOKIE: &str = "akurai_oauth_state";

type Handler = Box<dyn Fn(&Request) -> Response + Send + Sync>;

/// A login failure shown to the browser: HTTP status plus a safe message.
#[derive(Debug, PartialEq)]
pub struct LoginError {
    pub status: u16,
    pub message: String,
}

impl LoginError {
    fn new(status: u16, message: impl Into<String>) -> Self {
        Self {
            status,
            message: message.into(),
        }
    }

    fn response(&self) -> Response {
        Response::new(self.status)
            .with_text(&self.message)
            .with_header("Cache-Control", "no-store")
    }
}

/// Where the browser goes after a successful login.
#[derive(Debug, PartialEq)]
pub struct LoggedIn {
    pub session_id: String,
    pub return_to: String,
}

fn now() -> i64 {
    CrmState::now()
}

fn with_db<T>(
    state: &Arc<Mutex<CrmState>>,
    f: impl FnOnce(&mut BTree) -> T,
) -> Result<T, Response> {
    let state = state.lock().map_err(|_| internal_error("lock poisoned"))?;
    let mut db: MutexGuard<'_, BTree> = state
        .db
        .lock()
        .map_err(|_| internal_error("db lock poisoned"))?;
    Ok(f(&mut db))
}

fn query_param(req: &Request, name: &str) -> Option<String> {
    req.query.as_deref()?.split('&').find_map(|pair| {
        let (key, value) = pair.split_once('=')?;
        (key == name).then(|| urldecode(value))
    })
}

fn redirect(location: &str) -> Response {
    Response::new(302)
        .with_header("Location", location)
        .with_header("Cache-Control", "no-store")
}

fn with_cookie(mut response: Response, cookie: &str) -> Response {
    response
        .headers
        .push(("Set-Cookie".to_string(), cookie.to_string()));
    response
}

fn state_cookie(value: &str, max_age: i64) -> String {
    format!("{STATE_COOKIE}={value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age}")
}

fn sso_not_configured(error: &str) -> Response {
    Response::new(503).with_text(&format!(
        "akurai-crm: single sign-on is not configured: {error}"
    ))
}

/// Start a login: persist state + PKCE verifier + nonce, redirect to the IdP.
pub fn begin_login(
    config: &Config,
    db: &mut BTree,
    return_to: Option<&str>,
    now: i64,
) -> Result<Response, String> {
    let state = pkce::random_token()?;
    let login = LoginState {
        code_verifier: pkce::random_token()?,
        nonce: pkce::random_token()?,
        return_to: login_state::safe_return_to(return_to),
    };
    login_state::store(db, &state, &login, now)?;
    let location = config.authorization_url(
        &state,
        &login.nonce,
        &pkce::s256_challenge(&login.code_verifier),
    );
    Ok(with_cookie(
        redirect(&location),
        &state_cookie(&state, login_state::LOGIN_STATE_TTL_SECS),
    ))
}

/// Finish a login from the IdP callback request.
pub fn complete_login(
    config: &Config,
    db: &mut BTree,
    req: &Request,
    now: i64,
) -> Result<LoggedIn, LoginError> {
    let bad = |message: &str| LoginError::new(400, message);
    let upstream = |message: String| LoginError::new(502, message);

    if let Some(error) = query_param(req, "error") {
        return Err(bad(&format!("login was not completed: {error}")));
    }
    let state = query_param(req, "state")
        .filter(|s| !s.is_empty())
        .ok_or_else(|| bad("invalid_login_response"))?;
    let cookie_state =
        session::cookie(req, STATE_COOKIE).ok_or_else(|| bad("state cookie is missing"))?;
    if cookie_state != state {
        return Err(bad("state does not match the state cookie"));
    }
    let login = login_state::take(db, &state, now)
        .ok_or_else(|| bad("state is unknown, expired, or already used"))?;
    let code = query_param(req, "code")
        .filter(|c| !c.is_empty())
        .ok_or_else(|| bad("code is missing"))?;

    let tokens = config
        .exchange_code(&code, &login.code_verifier)
        .map_err(upstream)?;
    let identity = id_token::validate(
        &tokens.id_token,
        &id_token::Expected {
            issuer: &config.issuer,
            client_id: &config.client_id,
            nonce: &login.nonce,
            now,
        },
    )
    .map_err(upstream)?;
    let subject = identity.sub;

    let introspection = config.introspect(&tokens.access_token).map_err(upstream)?;
    let active = introspection.get("active") == Some(&Value::Bool(true));
    let introspected_sub = introspection.get("sub").and_then(Value::as_str);
    if !active || introspected_sub != Some(subject.as_str()) {
        return Err(LoginError::new(
            401,
            "access token is not active for this subject",
        ));
    }

    let user = config.userinfo(&tokens.access_token).map_err(upstream)?;
    if user.sub != subject {
        return Err(upstream(
            "userinfo subject does not match the id_token".into(),
        ));
    }
    if !idp_client::groups_allowed(&config.allowed_groups, &user.groups) {
        return Err(LoginError::new(403, idp_client::GROUP_DENIED));
    }

    let session_id = session::create_with_role(
        db,
        &user.sub,
        &user.email,
        &user.tenant_id,
        session::Role::parse(&identity.role),
    )
    .map_err(|e| LoginError::new(500, e))?;
    Ok(LoggedIn {
        session_id,
        return_to: login.return_to,
    })
}

/// GET /auth/login[?return_to=/path] — redirect to the IdP.
pub fn login_route(state: Arc<Mutex<CrmState>>) -> Handler {
    Box::new(move |req: &Request| {
        let config = match Config::from_env() {
            Ok(config) => config,
            Err(error) => return sso_not_configured(&error),
        };
        let return_to = query_param(req, "return_to");
        match with_db(&state, |db| {
            begin_login(&config, db, return_to.as_deref(), now())
        }) {
            Ok(Ok(response)) => response,
            Ok(Err(error)) => internal_error(&error),
            Err(response) => response,
        }
    })
}

/// GET /auth/callback — validate, create the session, return to the app.
pub fn callback_route(state: Arc<Mutex<CrmState>>) -> Handler {
    Box::new(move |req: &Request| {
        let config = match Config::from_env() {
            Ok(config) => config,
            Err(error) => return sso_not_configured(&error),
        };
        let outcome = match with_db(&state, |db| complete_login(&config, db, req, now())) {
            Ok(outcome) => outcome,
            Err(response) => return response,
        };
        let response = match outcome {
            Ok(logged_in) => with_cookie(
                redirect(&logged_in.return_to),
                &session::set_cookie(&logged_in.session_id),
            ),
            Err(error) => error.response(),
        };
        with_cookie(response, &state_cookie("", 0))
    })
}

/// GET /api/me — current user from the session.
pub fn me_route(state: Arc<Mutex<CrmState>>) -> Handler {
    Box::new(move |req: &Request| {
        let user = match session::extract_session_id(req) {
            Some(sid) => match with_db(&state, |db| session::get_user(db, &sid)) {
                Ok(user) => user,
                Err(response) => return response,
            },
            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),
            )])),
        }
    })
}

/// Drop the local session and send the browser to the IdP's `/logout`
/// (or home when SSO is not configured).
pub fn logout(config: Option<&Config>, db: &mut BTree, req: &Request) -> Response {
    if let Some(sid) = session::extract_session_id(req) {
        if let Err(error) = session::delete(db, &sid) {
            return internal_error(&error);
        }
    }
    let location = config.map_or_else(|| "/".to_string(), Config::logout_url);
    with_cookie(redirect(&location), &session::clear_cookie())
}

/// GET /auth/logout
pub fn logout_route(state: Arc<Mutex<CrmState>>) -> Handler {
    Box::new(move |req: &Request| {
        let config = Config::from_env().ok();
        with_db(&state, |db| logout(config.as_ref(), db, req)).unwrap_or_else(|response| response)
    })
}

/// Fresh database path under `target/test-db` (never /tmp).
#[cfg(test)]
pub(crate) fn test_db_path(name: &str) -> String {
    let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-db");
    std::fs::create_dir_all(dir).unwrap();
    let path = format!("{dir}/{name}-{}.db", std::process::id());
    let _ = std::fs::remove_file(&path);
    path
}

#[cfg(test)]
pub(crate) fn test_db(name: &str) -> BTree {
    BTree::open(test_db_path(name)).unwrap()
}

#[cfg(test)]
mod tests;