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}");
}
}