AkurAI Build
Menu

Public repository / main

AkurAI-CRM / crates/crm-mcp/tests/oauth_test.rs

Latest change 46f8d3437352b9043c8406756a07d2c52c892d19 - Gate MCP merge tool on IdP role; send app.js 401s through login by Ólafur Búi Ólafsson

//! The MCP endpoint is an OAuth 2.0 protected resource for the AkurAI IdP.

use akurai_http::{Method, Request};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use crm_mcp::oauth::{validate_access_token, Jwks, ResourceConfig, TokenVerifier};
use crm_mcp::{McpConfig, McpServer, StaticCredential};
use ring::rand::SystemRandom;
use ring::signature::{Ed25519KeyPair, KeyPair};
use std::sync::Arc;

const KID: &str = "test-key";
const NOW: i64 = 1_800_000_000;

fn config() -> ResourceConfig {
    ResourceConfig {
        public_url: "https://crm.test/".into(),
        issuer: "https://auth.test".into(),
        audience: "akurai-crm".into(),
    }
}

fn key_pair() -> Ed25519KeyPair {
    let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
    Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap()
}

fn jwks_json(key: &Ed25519KeyPair) -> String {
    format!(
        r#"{{"keys":[{{"alg":"EdDSA","crv":"Ed25519","kid":"{KID}","kty":"OKP","use":"sig","x":"{}"}}]}}"#,
        URL_SAFE_NO_PAD.encode(key.public_key().as_ref())
    )
}

fn sign(key: &Ed25519KeyPair, header: &str, claims: &str) -> String {
    let input = format!(
        "{}.{}",
        URL_SAFE_NO_PAD.encode(header),
        URL_SAFE_NO_PAD.encode(claims)
    );
    let signature = key.sign(input.as_bytes());
    format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature.as_ref()))
}

fn token(key: &Ed25519KeyPair, claims: &str) -> String {
    sign(
        key,
        &format!(r#"{{"alg":"EdDSA","kid":"{KID}","typ":"JWT"}}"#),
        claims,
    )
}

fn user_claims(aud: &str) -> String {
    format!(
        r#"{{"iss":"https://auth.test","sub":"user-1","aud":{aud},"exp":{},"iat":{NOW},"scope":"openid profile email groups","tenant_id":"tenant-a","organization_id":"tenant-a"}}"#,
        NOW + 60
    )
}

const AGENT_AUD: &str = r#"["akurai-agent-crm","akurai-crm"]"#;

#[test]
fn metadata_names_the_resource_and_authorization_server() {
    let metadata = akurai_json::parse(&config().metadata_json()).unwrap();
    assert_eq!(
        metadata.get("resource").and_then(|v| v.as_str()),
        Some("https://crm.test/mcp")
    );
    let servers = metadata.get("authorization_servers").unwrap().to_json();
    assert_eq!(servers, r#"["https://auth.test"]"#);
    assert_eq!(
        metadata.get("bearer_methods_supported").unwrap().to_json(),
        r#"["header"]"#
    );
}

#[test]
fn challenge_points_at_resource_metadata() {
    let cfg = config();
    assert_eq!(
        cfg.challenge(false),
        r#"Bearer resource_metadata="https://crm.test/.well-known/oauth-protected-resource/mcp""#
    );
    assert!(cfg
        .challenge(true)
        .starts_with(r#"Bearer error="invalid_token", "#));
}

#[test]
fn accepts_agent_token_whose_audience_array_contains_the_app() {
    let key = key_pair();
    let jwks = Jwks::parse(&jwks_json(&key)).unwrap();
    let grant = validate_access_token(&token(&key, &user_claims(AGENT_AUD)), &jwks, &config(), NOW)
        .unwrap();
    assert_eq!(grant.sub, "user-1");
    assert_eq!(grant.tenant, "tenant-a");
    assert!(grant.scopes.iter().any(|s| s == "groups"));
}

#[test]
fn accepts_string_audience() {
    let key = key_pair();
    let jwks = Jwks::parse(&jwks_json(&key)).unwrap();
    let jwt = token(&key, &user_claims(r#""akurai-crm""#));
    assert!(validate_access_token(&jwt, &jwks, &config(), NOW).is_ok());
}

#[test]
fn rejects_other_audiences() {
    let key = key_pair();
    let jwks = Jwks::parse(&jwks_json(&key)).unwrap();
    let jwt = token(
        &key,
        &user_claims(r#"["akurai-agent-notes","akurai-notes"]"#),
    );
    assert!(validate_access_token(&jwt, &jwks, &config(), NOW).is_err());
}

#[test]
fn rejects_bad_signature_issuer_expiry_and_algorithm() {
    let key = key_pair();
    let other = key_pair();
    let jwks = Jwks::parse(&jwks_json(&key)).unwrap();
    let cfg = config();
    let forged = token(&other, &user_claims(AGENT_AUD));
    assert!(validate_access_token(&forged, &jwks, &cfg, NOW).is_err());
    let wrong_issuer = token(
        &key,
        &user_claims(AGENT_AUD).replace("https://auth.test", "https://evil.test"),
    );
    assert!(validate_access_token(&wrong_issuer, &jwks, &cfg, NOW).is_err());
    let valid = token(&key, &user_claims(AGENT_AUD));
    assert!(validate_access_token(&valid, &jwks, &cfg, NOW + 61).is_err());
    let hs256 = sign(
        &key,
        &format!(r#"{{"alg":"HS256","kid":"{KID}"}}"#),
        &user_claims(AGENT_AUD),
    );
    assert!(validate_access_token(&hs256, &jwks, &cfg, NOW).is_err());
    assert!(validate_access_token("not-a-jwt", &jwks, &cfg, NOW).is_err());
}

#[test]
fn rejects_client_credentials_and_tenantless_tokens() {
    let key = key_pair();
    let jwks = Jwks::parse(&jwks_json(&key)).unwrap();
    let client = token(
        &key,
        &user_claims(AGENT_AUD).replace(
            r#""sub":"user-1""#,
            r#""sub":"akurai-agent-crm","client_id":"akurai-agent-crm""#,
        ),
    );
    assert!(validate_access_token(&client, &jwks, &config(), NOW).is_err());
    let tenantless = token(
        &key,
        &user_claims(AGENT_AUD)
            .replace(r#""tenant_id":"tenant-a","#, "")
            .replace(r#","organization_id":"tenant-a""#, ""),
    );
    assert!(validate_access_token(&tenantless, &jwks, &config(), NOW).is_err());
}

// --- HTTP endpoint -----------------------------------------------------------

fn db_path(name: &str) -> String {
    let dir = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR"))
        .join(format!("crm-mcp-oauth-{}-{name}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    dir.join("crm.db").to_string_lossy().into_owned()
}

fn server(name: &str, jwks: String) -> Arc<McpServer> {
    let verifier = TokenVerifier::new(config(), Box::new(move || Jwks::parse(&jwks)));
    Arc::new(
        McpServer::with_auth(
            McpConfig {
                db_path: db_path(name),
            },
            Some(StaticCredential {
                token: "static-secret".into(),
                tenant: "tenant-static".into(),
            }),
            Some(verifier),
        )
        .unwrap(),
    )
}

fn post(headers: &[(&str, &str)]) -> Request {
    Request {
        method: Method::Post,
        path: "/mcp".into(),
        query: None,
        version: "HTTP/1.1".into(),
        headers: headers
            .iter()
            .map(|(k, v)| (k.to_string(), v.to_string()))
            .collect(),
        body: br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#.to_vec(),
        peer: None,
    }
}

fn header<'a>(resp: &'a akurai_http::Response, name: &str) -> Option<&'a str> {
    resp.headers
        .iter()
        .find(|(k, _)| k.eq_ignore_ascii_case(name))
        .map(|(_, v)| v.as_str())
}

#[test]
fn missing_credential_gets_a_401_challenge() {
    let key = key_pair();
    let handler = server("missing", jwks_json(&key)).http_handler();
    let resp = handler(&post(&[]));
    assert_eq!(resp.status, 401);
    assert_eq!(
        header(&resp, "WWW-Authenticate"),
        Some(
            r#"Bearer resource_metadata="https://crm.test/.well-known/oauth-protected-resource/mcp""#
        )
    );
}

#[test]
fn invalid_token_gets_invalid_token_challenge() {
    let key = key_pair();
    let handler = server("invalid", jwks_json(&key)).http_handler();
    let resp = handler(&post(&[("Authorization", "Bearer a.b.c")]));
    assert_eq!(resp.status, 401);
    assert!(header(&resp, "WWW-Authenticate")
        .unwrap()
        .contains(r#"error="invalid_token""#));
}

#[test]
fn idp_token_is_accepted_and_scoped_to_its_tenant() {
    let key = key_pair();
    let handler = server("idp", jwks_json(&key)).http_handler();
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_secs() as i64;
    let claims = user_claims(AGENT_AUD).replace(&format!("{}", NOW + 60), &format!("{}", now + 60));
    let bearer = format!("Bearer {}", token(&key, &claims));
    let ok = handler(&post(&[("Authorization", &bearer)]));
    assert_eq!(ok.status, 200, "{}", String::from_utf8_lossy(&ok.body));
    let same = handler(&post(&[
        ("Authorization", &bearer),
        ("X-Tenant-Id", "tenant-a"),
    ]));
    assert_eq!(same.status, 200);
    let other = handler(&post(&[
        ("Authorization", &bearer),
        ("X-Tenant-Id", "tenant-b"),
    ]));
    assert_eq!(other.status, 403);
}

#[test]
fn static_credential_still_works() {
    let key = key_pair();
    let handler = server("static", jwks_json(&key)).http_handler();
    let ok = handler(&post(&[
        ("Authorization", "Bearer static-secret"),
        ("X-Tenant-Id", "tenant-static"),
    ]));
    assert_eq!(ok.status, 200);
    let wrong_tenant = handler(&post(&[
        ("Authorization", "Bearer static-secret"),
        ("X-Tenant-Id", "tenant-x"),
    ]));
    assert_eq!(wrong_tenant.status, 403);
}

#[test]
fn cross_origin_browser_requests_are_refused() {
    let key = key_pair();
    let handler = server("origin", jwks_json(&key)).http_handler();
    let resp = handler(&post(&[
        ("Origin", "https://evil.test"),
        ("Authorization", "Bearer static-secret"),
        ("X-Tenant-Id", "tenant-static"),
    ]));
    assert_eq!(resp.status, 403);
}

// --- Allowed groups (AKURAI_IDP_ALLOWED_GROUPS) --------------------------------

fn fresh_bearer(key: &Ed25519KeyPair) -> String {
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_secs() as i64;
    let claims = user_claims(AGENT_AUD).replace(&format!("{}", NOW + 60), &format!("{}", now + 60));
    format!("Bearer {}", token(key, &claims))
}

fn grouped_server(
    name: &str,
    jwks: String,
    user_groups: &'static [&'static str],
) -> Arc<McpServer> {
    let verifier = TokenVerifier::new(config(), Box::new(move || Jwks::parse(&jwks)))
        .with_allowed_groups(
            vec!["crm-users".to_string()],
            Box::new(move |_token| Ok(user_groups.iter().map(|g| g.to_string()).collect())),
        );
    Arc::new(
        McpServer::with_auth(
            McpConfig {
                db_path: db_path(name),
            },
            None,
            Some(verifier),
        )
        .unwrap(),
    )
}

#[test]
fn idp_token_of_allowed_group_member_is_accepted() {
    let key = key_pair();
    let handler = grouped_server("group-ok", jwks_json(&key), &["crm-users"]).http_handler();
    let resp = handler(&post(&[("Authorization", &fresh_bearer(&key))]));
    assert_eq!(resp.status, 200, "{}", String::from_utf8_lossy(&resp.body));
}

#[test]
fn idp_token_outside_allowed_groups_is_forbidden() {
    let key = key_pair();
    let handler = grouped_server("group-deny", jwks_json(&key), &["other"]).http_handler();
    let resp = handler(&post(&[("Authorization", &fresh_bearer(&key))]));
    assert_eq!(resp.status, 403);
    assert!(String::from_utf8_lossy(&resp.body)
        .contains("your account is not a member of a group allowed in AkurAI-CRM"));
}

// --- Admin-only tools (role claim) ----------------------------------------------

fn bearer_with_role(key: &Ed25519KeyPair, role: Option<&str>) -> String {
    let bearer = fresh_bearer(key);
    let Some(role) = role else { return bearer };
    // Re-sign the same fresh claims with a role claim added.
    let payload = bearer
        .trim_start_matches("Bearer ")
        .split('.')
        .nth(1)
        .unwrap();
    let claims = String::from_utf8(URL_SAFE_NO_PAD.decode(payload).unwrap()).unwrap();
    let claims = claims.replacen('{', &format!(r#"{{"role":"{role}","#), 1);
    format!("Bearer {}", token(key, &claims))
}

fn merge_call(handler: &dyn Fn(&Request) -> akurai_http::Response, auth: &str) -> String {
    let mut req = post(&[("Authorization", auth)]);
    req.body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"merge_account_companies","arguments":{"tenantId":"TENANT","sourceCompanyId":1,"targetCompanyId":2}}}"#.to_vec();
    let tenant = if auth.contains("static-secret") {
        "tenant-static"
    } else {
        "tenant-a"
    };
    req.body = String::from_utf8(req.body)
        .unwrap()
        .replace("TENANT", tenant)
        .into_bytes();
    String::from_utf8_lossy(&handler(&req).body).into_owned()
}

const ADMIN_REQUIRED: &str = "this action requires an AkurAI platform administrator";

#[test]
fn merge_tool_refuses_member_and_roleless_idp_tokens() {
    let key = key_pair();
    let handler = server("merge-member", jwks_json(&key)).http_handler();
    for role in [Some("member"), Some("superuser"), None] {
        let body = merge_call(&*handler, &bearer_with_role(&key, role));
        assert!(body.contains(ADMIN_REQUIRED), "{role:?}: {body}");
    }
}

#[test]
fn merge_tool_admits_admin_owner_and_static_credential() {
    let key = key_pair();
    let handler = server("merge-admin", jwks_json(&key)).http_handler();
    for auth in [
        bearer_with_role(&key, Some("admin")),
        bearer_with_role(&key, Some("owner")),
        "Bearer static-secret".to_string(),
    ] {
        let body = merge_call(&*handler, &auth);
        assert!(!body.contains(ADMIN_REQUIRED), "{body}");
    }
}