AkurAI Build
Menu

Public repository / 4fac5c7826068a0d615a09851633816b41204468

AkurAI-Notes / crates/notes-mcp/tests/oauth_test.rs

Latest change 228bc96dddc8e5490cfc2ed27705c0d1dbbfaf83 - MCP: OAuth protected resource for the AkurAI IdP by Ólafur Búi Ólafsson

use akurai_http::{Request, Response};
use akurai_storage::BTree;
use jsonwebtoken::jwk::JwkSet;
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use notes_mcp::oauth::{
    audience_contains, identity_from_userinfo, protected_resource_metadata, validate_access_token,
    www_authenticate, IdpClient, OAuth, OAuthConfig,
};
use notes_mcp::server::{McpConfig, McpServer};
use serde_json::{json, Value};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};

const ISS: &str = "https://auth.olibuijr.com";
const AUD: &str = "akurai-notes";
const PUBLIC: &str = "https://akurai-notes.olibuijr.com";
const STATIC_TOKEN: &str = "static-token";

fn jwks() -> JwkSet {
    serde_json::from_value(json!({"keys": [{
        "kty": "OKP", "use": "sig", "crv": "Ed25519", "kid": "fixture", "alg": "EdDSA",
        "x": "2-Jj2UvNCvQiUPNYRgSi0cJSPiJI6Rs6D0UTeEpQVj8"
    }]}))
    .unwrap()
}

fn sign_with_kid(claims: Value, kid: &str) -> String {
    let mut header = Header::new(Algorithm::EdDSA);
    header.kid = Some(kid.into());
    let key = EncodingKey::from_ed_pem(
        b"-----BEGIN PRIVATE KEY-----\n\
          MC4CAQAwBQYDK2VwBCIEIGrD/e7uKYqSY4twDEsRfMMuLSrODf14dpTiTK6K1YI0\n\
          -----END PRIVATE KEY-----\n",
    )
    .unwrap();
    encode(&header, &claims, &key).unwrap()
}

fn sign(claims: Value) -> String {
    sign_with_kid(claims, "fixture")
}

fn now() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs()
}

fn user_claims() -> Value {
    json!({"iss": ISS, "sub": "user-1", "aud": ["akurai-agent-akurai-notes", AUD],
           "exp": now() + 300, "scope": "openid profile email", "tenant_id": "t1",
           "organization_id": "o1"})
}

fn config() -> OAuthConfig {
    OAuthConfig {
        public_url: format!("{PUBLIC}/"),
        issuer: ISS.into(),
        audience: AUD.into(),
        allow: vec!["owner@example.com".into()],
    }
}

struct FakeIdp {
    userinfo: Value,
}

impl IdpClient for FakeIdp {
    fn jwks(&self, _force: bool) -> Result<JwkSet, &'static str> {
        Ok(jwks())
    }
    fn userinfo(&self, _token: &str) -> Result<Value, &'static str> {
        Ok(self.userinfo.clone())
    }
}

fn owner_userinfo() -> Value {
    json!({"sub": "user-1", "email": "Owner@Example.com", "email_verified": true})
}

fn server_with(userinfo: Value) -> McpServer {
    let dir = std::env::temp_dir().join(format!("notes-oauth-test-{}", std::process::id()));
    let _ = std::fs::create_dir_all(&dir);
    let db_path = dir.join(format!(
        "test-{}.db",
        SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos()
    ));
    let db = BTree::open(&db_path).unwrap();
    McpServer::with_db(
        McpConfig {
            host: "127.0.0.1".into(),
            port: 0,
            db: db_path.display().to_string(),
            token: STATIC_TOKEN.into(),
            embed_url: None,
            embed_model: None,
            embed_api_key: None,
            owner_sub: "owner".into(),
        },
        Arc::new(Mutex::new(db)),
    )
    .with_oauth(OAuth::new(config(), Box::new(FakeIdp { userinfo })))
}

fn request(head: &str) -> Request {
    let mut req = Request::parse_head(head.as_bytes()).unwrap();
    req.body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#.to_vec();
    req
}

fn mcp_post(extra: &str) -> Request {
    request(&format!("POST /mcp HTTP/1.1\r\n{extra}\r\n"))
}

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

// --- pure helpers --------------------------------------------------------

#[test]
fn metadata_names_resource_and_idp() {
    let m = protected_resource_metadata(&config());
    assert_eq!(m["resource"], "https://akurai-notes.olibuijr.com/mcp");
    assert_eq!(m["authorization_servers"], json!([ISS]));
    assert_eq!(m["bearer_methods_supported"], json!(["header"]));
    assert!(m["scopes_supported"]
        .as_array()
        .unwrap()
        .contains(&json!("openid")));
}

#[test]
fn challenge_points_at_metadata() {
    assert_eq!(
        www_authenticate(&config(), false),
        "Bearer resource_metadata=\"https://akurai-notes.olibuijr.com/.well-known/oauth-protected-resource/mcp\""
    );
    assert!(www_authenticate(&config(), true).starts_with("Bearer error=\"invalid_token\", "));
}

#[test]
fn audience_matches_string_or_array() {
    assert!(audience_contains(&json!(AUD), AUD));
    assert!(audience_contains(&json!(["x", AUD]), AUD));
    assert!(!audience_contains(&json!(["x"]), AUD));
    assert!(!audience_contains(&json!(null), AUD));
}

#[test]
fn accepts_idp_user_token_with_app_audience() {
    let grant = validate_access_token(&sign(user_claims()), &jwks(), ISS, AUD).unwrap();
    assert_eq!(grant.sub, "user-1");
    assert!(grant.scopes.contains("email"));
}

#[test]
fn rejects_wrong_audience_issuer_expiry_and_key() {
    let mut c = user_claims();
    c["aud"] = json!(["akurai-agent-other", "other-app"]);
    assert!(validate_access_token(&sign(c), &jwks(), ISS, AUD).is_err());

    let mut c = user_claims();
    c["iss"] = json!("https://evil.test");
    assert!(validate_access_token(&sign(c), &jwks(), ISS, AUD).is_err());

    let mut c = user_claims();
    c["exp"] = json!(now() - 10);
    assert!(validate_access_token(&sign(c), &jwks(), ISS, AUD).is_err());

    assert_eq!(
        validate_access_token(&sign_with_kid(user_claims(), "other"), &jwks(), ISS, AUD),
        Err("unknown JWT key id")
    );
    assert!(validate_access_token("not.a.jwt", &jwks(), ISS, AUD).is_err());
}

#[test]
fn rejects_client_credentials_tokens() {
    let mut c = user_claims();
    c["client_id"] = json!("akurai-agent-akurai-notes");
    assert!(validate_access_token(&sign(c), &jwks(), ISS, AUD).is_err());
}

#[test]
fn userinfo_identity_must_match_and_be_allowed() {
    let allow = vec!["owner@example.com".to_string()];
    assert_eq!(
        identity_from_userinfo(&owner_userinfo(), "user-1", &allow).unwrap(),
        "owner@example.com"
    );
    assert!(identity_from_userinfo(&owner_userinfo(), "user-2", &allow).is_err());
    let stranger = json!({"sub": "user-1", "email": "x@example.com", "email_verified": true});
    assert!(identity_from_userinfo(&stranger, "user-1", &allow).is_err());
    let unverified =
        json!({"sub": "user-1", "email": "owner@example.com", "email_verified": false});
    assert!(identity_from_userinfo(&unverified, "user-1", &allow).is_err());
}

// --- HTTP surface --------------------------------------------------------

#[test]
fn serves_protected_resource_metadata_on_both_paths() {
    let server = server_with(owner_userinfo());
    for path in [
        "/.well-known/oauth-protected-resource",
        "/.well-known/oauth-protected-resource/mcp",
    ] {
        let resp = server.handle_http(&request(&format!("GET {path} HTTP/1.1\r\n\r\n")));
        assert_eq!(resp.status, 200, "{path}");
        let body: Value = serde_json::from_slice(&resp.body).unwrap();
        assert_eq!(body["resource"], "https://akurai-notes.olibuijr.com/mcp");
    }
}

#[test]
fn missing_token_gets_resource_metadata_challenge() {
    let resp = server_with(owner_userinfo()).handle_http(&mcp_post(""));
    assert_eq!(resp.status, 401);
    assert_eq!(
        header(&resp, "WWW-Authenticate"),
        Some(www_authenticate(&config(), false).as_str())
    );
}

#[test]
fn rejected_token_gets_invalid_token_challenge() {
    let resp =
        server_with(owner_userinfo()).handle_http(&mcp_post("Authorization: Bearer nope\r\n"));
    assert_eq!(resp.status, 401);
    assert_eq!(
        header(&resp, "WWW-Authenticate"),
        Some(www_authenticate(&config(), true).as_str())
    );
}

#[test]
fn static_token_still_works() {
    let resp = server_with(owner_userinfo()).handle_http(&mcp_post(&format!(
        "Authorization: Bearer {STATIC_TOKEN}\r\n"
    )));
    assert_eq!(resp.status, 200);
}

#[test]
fn idp_token_for_allowed_user_is_accepted() {
    let token = sign(user_claims());
    let resp = server_with(owner_userinfo())
        .handle_http(&mcp_post(&format!("Authorization: Bearer {token}\r\n")));
    assert_eq!(resp.status, 200);
    assert!(String::from_utf8(resp.body).unwrap().contains("list_notes"));
}

#[test]
fn idp_token_for_disallowed_user_is_rejected() {
    let token = sign(user_claims());
    let stranger = json!({"sub": "user-1", "email": "x@example.com", "email_verified": true});
    let resp =
        server_with(stranger).handle_http(&mcp_post(&format!("Authorization: Bearer {token}\r\n")));
    assert_eq!(resp.status, 401);
}

#[test]
fn foreign_browser_origin_is_forbidden() {
    let resp = server_with(owner_userinfo()).handle_http(&mcp_post(&format!(
        "Authorization: Bearer {STATIC_TOKEN}\r\nOrigin: https://evil.test\r\n"
    )));
    assert_eq!(resp.status, 403);
    let same = server_with(owner_userinfo()).handle_http(&mcp_post(&format!(
        "Authorization: Bearer {STATIC_TOKEN}\r\nOrigin: {PUBLIC}\r\n"
    )));
    assert_eq!(same.status, 200);
}