AkurAI Build
Menu

Public repository / main

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

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

use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;

/// Minimal IdP double: answers by request path, records request heads.
struct FakeIdp {
    url: String,
    requests: Arc<Mutex<Vec<String>>>,
}

fn fake_idp(routes: Vec<(&'static str, u16, String)>) -> FakeIdp {
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let url = format!("http://{}", listener.local_addr().unwrap());
    let requests = Arc::new(Mutex::new(Vec::new()));
    let seen = Arc::clone(&requests);
    std::thread::spawn(move || {
        for stream in listener.incoming() {
            let Ok(mut stream) = stream else { break };
            let mut buf = [0_u8; 8192];
            let n = stream.read(&mut buf).unwrap_or(0);
            let request = String::from_utf8_lossy(&buf[..n]).to_string();
            let path = request.split(' ').nth(1).unwrap_or("").to_string();
            seen.lock().unwrap().push(request);
            let (status, body) = routes
                .iter()
                .find(|(p, _, _)| *p == path)
                .map(|(_, s, b)| (*s, b.clone()))
                .unwrap_or((404, String::new()));
            let _ = write!(
                stream,
                "HTTP/1.1 {status} X\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
                body.len()
            );
        }
    });
    FakeIdp { url, requests }
}

const NOW: i64 = 1_000;

fn id_token(nonce: &str) -> String {
    id_token::encode_for_test(&format!(
        r#"{{"iss":"https://auth.test","aud":"akurai-crm","exp":2000,"nonce":"{nonce}","sub":"u1","role":"admin"}}"#
    ))
}

fn happy_routes(nonce: &str, groups: &str) -> Vec<(&'static str, u16, String)> {
    vec![
        (
            "/token",
            200,
            format!(
                r#"{{"access_token":"at-1","id_token":"{}","token_type":"Bearer"}}"#,
                id_token(nonce)
            ),
        ),
        ("/introspect", 200, r#"{"active":true,"sub":"u1"}"#.into()),
        (
            "/userinfo",
            200,
            format!(r#"{{"sub":"u1","email":"u@x","tenant_id":"org-1","groups":{groups}}}"#),
        ),
    ]
}

fn config(url: &str, allowed: Option<&str>) -> Config {
    Config::from_settings(idp_client::Settings {
        allowed_groups: allowed.map(str::to_string),
        ..idp_client::test_settings(url)
    })
    .unwrap()
}

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

fn header<'a>(response: &'a Response, name: &str) -> Vec<&'a str> {
    response
        .headers
        .iter()
        .filter(|(k, _)| k.eq_ignore_ascii_case(name))
        .map(|(_, v)| v.as_str())
        .collect()
}

fn param<'a>(url: &'a str, name: &str) -> &'a str {
    url.split(['?', '&'])
        .find_map(|pair| pair.strip_prefix(&format!("{name}=")))
        .unwrap()
}

/// Start a login and return (state, nonce).
fn start(config: &Config, db: &mut BTree, return_to: Option<&str>) -> (String, String) {
    let response = begin_login(config, db, return_to, NOW).unwrap();
    let location = header(&response, "Location")[0].to_string();
    let state = param(&location, "state").to_string();
    let nonce = param(&location, "nonce").to_string();
    (state, nonce)
}

#[test]
fn login_redirects_with_pkce_and_sets_state_cookie() {
    let config = config("http://127.0.0.1:1", None);
    let mut db = test_db("auth-begin");
    let response = begin_login(&config, &mut db, Some("/people.html"), NOW).unwrap();
    assert_eq!(response.status, 302);
    let location = header(&response, "Location")[0];
    assert!(location.starts_with("https://auth.test/authorize?"));
    assert!(location.ends_with("&code_challenge_method=S256"));
    let state = param(location, "state");
    let stored = login_state::take(&mut db, state, NOW).unwrap();
    assert_eq!(stored.return_to, "/people.html");
    assert_eq!(
        param(location, "code_challenge"),
        pkce::s256_challenge(&stored.code_verifier)
    );
    assert_eq!(
        header(&response, "Set-Cookie"),
        vec![format!(
            "akurai_oauth_state={state}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600"
        )]
    );
}

#[test]
fn callback_creates_session_and_returns_to_saved_path() {
    let mut db = test_db("auth-happy");
    let placeholder = config("http://127.0.0.1:1", None);
    let (state, nonce) = start(&placeholder, &mut db, Some("/people.html"));
    let idp = fake_idp(happy_routes(&nonce, r#"["crm"]"#));
    let config = config(&idp.url, Some("crm,admins"));
    let req = request(
        &format!("/auth/callback?code=c1&state={state}"),
        &format!("akurai_oauth_state={state}"),
    );
    let logged_in = complete_login(&config, &mut db, &req, NOW).unwrap();
    assert_eq!(logged_in.return_to, "/people.html");
    let user = session::get_user(&mut db, &logged_in.session_id).unwrap();
    assert_eq!(
        (user.sub.as_str(), user.tenant_id.as_str()),
        ("u1", "org-1")
    );

    let requests = idp.requests.lock().unwrap();
    let token = requests
        .iter()
        .find(|r| r.starts_with("POST /token"))
        .unwrap();
    assert!(token.contains("Authorization: Basic "));
    assert!(token.contains("&code_verifier="));
    let introspect = requests
        .iter()
        .find(|r| r.starts_with("POST /introspect"))
        .unwrap();
    assert!(introspect.ends_with("token=at-1&token_type_hint=access_token"));
    assert!(requests
        .iter()
        .any(|r| r.starts_with("GET /userinfo") && r.contains("Bearer at-1")));
}

#[test]
fn callback_rejects_users_outside_allowed_groups() {
    let mut db = test_db("auth-groups");
    let (state, nonce) = start(&config("http://127.0.0.1:1", None), &mut db, None);
    let idp = fake_idp(happy_routes(&nonce, r#"["other"]"#));
    let req = request(
        &format!("/auth/callback?code=c1&state={state}"),
        &format!("akurai_oauth_state={state}"),
    );
    let error = complete_login(&config(&idp.url, Some("crm")), &mut db, &req, NOW).unwrap_err();
    assert_eq!(error, LoginError::new(403, idp_client::GROUP_DENIED));
}

#[test]
fn callback_rejects_inactive_access_token() {
    let mut db = test_db("auth-inactive");
    let (state, nonce) = start(&config("http://127.0.0.1:1", None), &mut db, None);
    let mut routes = happy_routes(&nonce, "[]");
    routes[1].2 = r#"{"active":false}"#.into();
    let idp = fake_idp(routes);
    let req = request(
        &format!("/auth/callback?code=c1&state={state}"),
        &format!("akurai_oauth_state={state}"),
    );
    let error = complete_login(&config(&idp.url, None), &mut db, &req, NOW).unwrap_err();
    assert_eq!(error.message, "access token is not active for this subject");
}

#[test]
fn callback_state_checks() {
    let config = config("http://127.0.0.1:1", None);
    let mut db = test_db("auth-state");
    let (state, _) = start(&config, &mut db, None);
    let cases = [
        ("/auth/callback?code=c", "", "invalid_login_response"),
        (
            &*format!("/auth/callback?code=c&state={state}"),
            "x=1",
            "state cookie is missing",
        ),
        (
            &*format!("/auth/callback?code=c&state={state}"),
            "akurai_oauth_state=other",
            "state does not match the state cookie",
        ),
        (
            "/auth/callback?code=c&state=unknown",
            "akurai_oauth_state=unknown",
            "state is unknown, expired, or already used",
        ),
        (
            &*format!("/auth/callback?state={state}"),
            &*format!("akurai_oauth_state={state}"),
            "code is missing",
        ),
    ];
    for (target, cookie, message) in cases {
        let error = complete_login(&config, &mut db, &request(target, cookie), NOW).unwrap_err();
        assert_eq!(
            (error.status, error.message.as_str()),
            (400, message),
            "{target}"
        );
    }
}

#[test]
fn logout_deletes_session_clears_cookie_and_goes_to_idp_logout() {
    let config = config("http://127.0.0.1:1", None);
    let mut db = test_db("auth-logout");
    let sid = session::create(&mut db, "u1", "e", "t").unwrap();
    let response = logout(
        Some(&config),
        &mut db,
        &request("/auth/logout", &format!("akurai_session={sid}")),
    );
    assert_eq!(response.status, 302);
    assert_eq!(
        header(&response, "Location"),
        vec!["https://auth.test/logout"]
    );
    assert_eq!(
        header(&response, "Set-Cookie"),
        vec![session::clear_cookie()]
    );
    assert!(session::get_user(&mut db, &sid).is_none());
}

#[test]
fn logout_without_sso_config_goes_home() {
    let mut db = test_db("auth-logout-nosso");
    let response = logout(None, &mut db, &request("/auth/logout", "x=1"));
    assert_eq!(header(&response, "Location"), vec!["/"]);
}