Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-mcp/tests/oauth_flow.rs

//! **The OAuth flow end to end**, in process: metadata → dynamic client
//! registration (the shapes claude.ai and Claude Code send) → the consent
//! page → a passkey touch → the code for a token with PKCE → a tool call →
//! revoke → refused.

mod common;

use common::*;
use http::StatusCode;
use serde_json::{json, Value};

#[tokio::test]
async fn the_metadata_is_what_an_mcp_client_reads() {
    let h = Harness::new();
    let unauth = h.rpc("", "initialize", json!({})).await;
    assert_eq!(unauth.status, StatusCode::UNAUTHORIZED);
    let challenge = unauth.headers["www-authenticate"].to_str().unwrap();
    assert!(
        challenge.contains(&format!(
            "resource_metadata=\"{ISSUER}/.well-known/oauth-protected-resource/mcp\""
        )),
        "{challenge}"
    );

    for path in [
        "/.well-known/oauth-protected-resource",
        "/.well-known/oauth-protected-resource/mcp",
    ] {
        let prm = h.get(path).await.json();
        assert_eq!(prm["resource"], format!("{ISSUER}/mcp"));
        assert_eq!(prm["authorization_servers"], json!([ISSUER]));
    }
    let asm = h
        .get("/.well-known/oauth-authorization-server")
        .await
        .json();
    assert_eq!(asm["issuer"], ISSUER);
    assert_eq!(
        asm["registration_endpoint"],
        format!("{ISSUER}/oauth/register")
    );
    assert_eq!(
        asm["code_challenge_methods_supported"],
        json!(["S256"]),
        "PKCE S256 only"
    );
    assert_eq!(asm["token_endpoint"], format!("{ISSUER}/oauth/token"));
    assert_eq!(asm["revocation_endpoint"], format!("{ISSUER}/oauth/revoke"));
}

#[tokio::test]
async fn claude_ais_registration_shape_is_accepted_and_answered_per_rfc_7591() {
    let h = Harness::new();
    let fixture: Value = serde_json::from_str(include_str!("fixtures/claude-ai-dcr.json")).unwrap();
    let reg = h.register(&fixture).await;
    assert_eq!(reg.status, StatusCode::CREATED, "{}", reg.body);
    let reg = reg.json();
    assert!(reg["client_id"].as_str().unwrap().len() > 20);
    assert!(
        reg["client_secret"].as_str().is_some(),
        "client_secret_post gets a secret"
    );
    assert_eq!(reg["client_secret_expires_at"], 0);
    assert_eq!(reg["redirect_uris"], fixture["redirect_uris"]);
    assert_eq!(reg["token_endpoint_auth_method"], "client_secret_post");
    // And the whole flow with it, secret and all.
    let c = h
        .connect_as(&fixture, "cred-kim", "gunnar:read", false)
        .await;
    assert_eq!(c.scope, "gunnar:read");
    let list = h.tool(&c.access, "list_repositories", json!({})).await;
    assert!(result_text(&list).0.contains("kim/secret"));
}

#[tokio::test]
async fn a_registration_that_would_send_codes_elsewhere_is_refused() {
    let h = Harness::new();
    for uris in [
        json!(["http://claude.ai/cb"]),
        json!(["javascript:alert(1)"]),
        json!([]),
        json!("https://claude.ai/cb"),
        json!(["https://a.invalid/cb#x"]),
    ] {
        let r = h.register(&json!({ "client_name": "x", "redirect_uris": uris, "token_endpoint_auth_method": "none" })).await;
        assert_eq!(r.status, StatusCode::BAD_REQUEST, "{uris}: {}", r.body);
        assert_eq!(r.json()["error"], "invalid_redirect_uri");
    }
    let r = h.register(&json!({ "redirect_uris": ["https://a.invalid/cb"], "grant_types": ["client_credentials"] })).await;
    assert_eq!(r.json()["error"], "invalid_client_metadata");
}

#[tokio::test]
async fn the_whole_flow_then_revoke_then_refused() {
    let h = Harness::new();
    let c = h.connect("cred-kim", "gunnar:read", false).await;

    let init = h.rpc(&c.access, "initialize", json!({ "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "1" } })).await;
    assert_eq!(init.status, StatusCode::OK, "{}", init.body);
    let init = init.json();
    assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
    assert!(init["result"]["capabilities"]["tools"].is_object());

    let list = h.tool(&c.access, "list_repositories", json!({})).await;
    let (text, err) = result_text(&list);
    assert!(
        !err && text.contains("kim/secret") && text.contains("pub/open"),
        "{text}"
    );

    // Every call is audited as the agent, acting for the person.
    {
        let seen = h.fake.0.lock().unwrap();
        let call = seen
            .audits
            .iter()
            .find(|a| a.event == "mcp.call")
            .expect("an audit record");
        assert!(
            call.actor.starts_with("mcp:Claude Code (gunnar)#")
                && call.actor.ends_with(" acting for kim"),
            "{}",
            call.actor
        );
        assert_eq!(call.tool, "list_repositories");
        assert_eq!(call.outcome, "ok");
        assert!(seen.audits.iter().any(|a| a.event == "mcp.grant"));
        for a in &seen.audits {
            let all = format!("{a:?}");
            assert!(
                !all.contains(&c.access) && !all.contains(&c.refresh) && !all.contains("gmcp_"),
                "no token in an audit record: {all}"
            );
        }
    }

    let revoke = h
        .post_form(
            "/oauth/revoke",
            &[
                ("token", c.access.as_str()),
                ("client_id", c.client_id.as_str()),
            ],
        )
        .await;
    assert_eq!(revoke.status, StatusCode::OK, "{}", revoke.body);
    let after = h.tool(&c.access, "list_repositories", json!({})).await;
    assert_eq!(after.status, StatusCode::UNAUTHORIZED, "{}", after.body);
    assert!(after.headers["www-authenticate"]
        .to_str()
        .unwrap()
        .contains("invalid_token"));
    // The refresh token died with it, and the agent's key was dropped at gunnar.
    let refresh = h
        .post_form(
            "/oauth/token",
            &[
                ("grant_type", "refresh_token"),
                ("refresh_token", c.refresh.as_str()),
                ("client_id", c.client_id.as_str()),
            ],
        )
        .await;
    assert_eq!(refresh.status, StatusCode::BAD_REQUEST);
    assert_eq!(h.fake.0.lock().unwrap().forgotten, vec!["kim#1".to_owned()]);
}

#[tokio::test]
async fn pkce_is_mandatory_and_must_match() {
    let h = Harness::new();
    let reg = h.register(&json!({ "client_name": "t", "redirect_uris": ["http://localhost:9/cb"], "token_endpoint_auth_method": "none" })).await.json();
    let id = reg["client_id"].as_str().unwrap();
    // No challenge: sent back with invalid_request, no page.
    let none = h
        .get(&format!(
            "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state=s",
            enc(id),
            enc("http://localhost:9/cb")
        ))
        .await;
    assert_eq!(none.status, StatusCode::FOUND);
    assert!(none.headers["location"]
        .to_str()
        .unwrap()
        .contains("error=invalid_request"));
    // `plain` is refused.
    let plain = h.get(&format!("/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&code_challenge={}&code_challenge_method=plain", enc(id), enc("http://localhost:9/cb"), VERIFIER)).await;
    assert!(plain.headers["location"]
        .to_str()
        .unwrap()
        .contains("error=invalid_request"));
    // A wrong verifier at the token endpoint is invalid_grant, and the code is spent.
    let page = h.authorize(id, "http://localhost:9/cb", "").await;
    let code = h.touch(&request_of(&page.body), "cred-kim", false).await;
    let wrong = h
        .post_form(
            "/oauth/token",
            &[
                ("grant_type", "authorization_code"),
                ("code", code.as_str()),
                ("redirect_uri", "http://localhost:9/cb"),
                ("code_verifier", &"x".repeat(50)),
                ("client_id", id),
            ],
        )
        .await;
    assert_eq!(wrong.status, StatusCode::BAD_REQUEST);
    assert_eq!(wrong.json()["error"], "invalid_grant");
    let again = h
        .post_form(
            "/oauth/token",
            &[
                ("grant_type", "authorization_code"),
                ("code", code.as_str()),
                ("redirect_uri", "http://localhost:9/cb"),
                ("code_verifier", VERIFIER),
                ("client_id", id),
            ],
        )
        .await;
    assert_eq!(
        again.json()["error"],
        "invalid_grant",
        "a code is used once"
    );
}

#[tokio::test]
async fn an_unregistered_redirect_gets_a_page_and_no_redirect() {
    let h = Harness::new();
    let reg = h.register(&json!({ "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"], "token_endpoint_auth_method": "none" })).await.json();
    let page = h
        .authorize(
            reg["client_id"].as_str().unwrap(),
            "https://evil.mock.invalid/cb",
            "",
        )
        .await;
    assert_eq!(page.status, StatusCode::BAD_REQUEST);
    assert!(page.headers.get("location").is_none());
    let unknown = h
        .authorize("nobody", "https://claude.ai/api/mcp/auth_callback", "")
        .await;
    assert_eq!(unknown.status, StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn a_wrong_passkey_gets_no_code() {
    let h = Harness::new();
    let reg = h.register(&json!({ "redirect_uris": ["http://localhost:9/cb"], "token_endpoint_auth_method": "none" })).await.json();
    let page = h
        .authorize(
            reg["client_id"].as_str().unwrap(),
            "http://localhost:9/cb",
            "",
        )
        .await;
    let request = request_of(&page.body);
    let done = h.post_json("/oauth/authorize/approve", &json!({ "request": request, "ceremony_id": "ceremony-1", "id": "cred-nobody", "response": {} })).await;
    assert_eq!(done.status, StatusCode::UNAUTHORIZED, "{}", done.body);
    // The page is spent either way.
    let twice = h.post_json("/oauth/authorize/approve", &json!({ "request": request, "ceremony_id": "ceremony-1", "id": "cred-kim", "response": {} })).await;
    assert_eq!(twice.status, StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn deny_sends_access_denied() {
    let h = Harness::new();
    let reg = h.register(&json!({ "redirect_uris": ["http://localhost:9/cb"], "token_endpoint_auth_method": "none" })).await.json();
    let page = h
        .authorize(
            reg["client_id"].as_str().unwrap(),
            "http://localhost:9/cb",
            "",
        )
        .await;
    let done = h
        .post_json(
            "/oauth/authorize/deny",
            &json!({ "request": request_of(&page.body) }),
        )
        .await
        .json();
    let to = done["redirect"].as_str().unwrap();
    assert!(
        to.starts_with("http://localhost:9/cb?error=access_denied&state=st-1"),
        "{to}"
    );
}

#[tokio::test]
async fn a_refresh_token_rotates_and_a_replayed_one_ends_the_grant() {
    let h = Harness::new();
    let c = h.connect("cred-kim", "", false).await;
    let first = h
        .post_form(
            "/oauth/token",
            &[
                ("grant_type", "refresh_token"),
                ("refresh_token", c.refresh.as_str()),
                ("client_id", c.client_id.as_str()),
            ],
        )
        .await;
    assert_eq!(first.status, StatusCode::OK, "{}", first.body);
    let first = first.json();
    let access2 = first["access_token"].as_str().unwrap().to_owned();
    assert_ne!(first["refresh_token"].as_str().unwrap(), c.refresh);
    assert_eq!(
        h.tool(&access2, "list_repositories", json!({}))
            .await
            .status,
        StatusCode::OK
    );
    // The old refresh token again: somebody has a copy, so the grant ends.
    let replay = h
        .post_form(
            "/oauth/token",
            &[
                ("grant_type", "refresh_token"),
                ("refresh_token", c.refresh.as_str()),
                ("client_id", c.client_id.as_str()),
            ],
        )
        .await;
    assert_eq!(replay.status, StatusCode::BAD_REQUEST);
    assert_eq!(
        h.tool(&access2, "list_repositories", json!({}))
            .await
            .status,
        StatusCode::UNAUTHORIZED
    );
}

#[tokio::test]
async fn a_person_lists_and_ends_their_own_connections_only() {
    let h = Harness::new();
    let kim = h.connect("cred-kim", "", false).await;
    let eve = h.connect("cred-eve", "", false).await;
    let mine = h.mcp.connections("kim");
    assert_eq!(mine.len(), 1);
    assert_eq!(mine[0].scope, "gunnar:read");
    assert!(
        mine[0].client.starts_with("Claude Code (gunnar)#"),
        "{:?}",
        mine[0]
    );
    let eves = h.mcp.connections("eve")[0].id;
    assert!(!h.mcp.disconnect("kim", eves).await, "not kim's to end");
    assert_eq!(
        h.tool(&eve.access, "list_repositories", json!({}))
            .await
            .status,
        StatusCode::OK
    );
    assert!(h.mcp.disconnect("kim", mine[0].id).await);
    assert_eq!(
        h.tool(&kim.access, "list_repositories", json!({}))
            .await
            .status,
        StatusCode::UNAUTHORIZED
    );
    assert!(h.mcp.connections("kim").is_empty());
}

#[tokio::test]
async fn a_key_gunnar_no_longer_knows_ends_the_connection() {
    let h = Harness::new();
    let c = h.connect("cred-kim", "", false).await;
    h.fake.0.lock().unwrap().gone.insert("kim#1".to_owned());
    let r = h.tool(&c.access, "list_repositories", json!({})).await;
    assert_eq!(r.status, StatusCode::UNAUTHORIZED);
    assert!(h.mcp.connections("kim").is_empty());
}

#[tokio::test]
async fn a_token_ages_out() {
    let mut config = gunnar_mcp::Config::new(ISSUER);
    config.access_ttl = std::time::Duration::from_secs(1);
    let h = Harness::with(config);
    let c = h.connect("cred-kim", "", false).await;
    assert_eq!(
        h.tool(&c.access, "list_repositories", json!({}))
            .await
            .status,
        StatusCode::OK
    );
    tokio::time::sleep(std::time::Duration::from_millis(1_200)).await;
    assert_eq!(
        h.tool(&c.access, "list_repositories", json!({}))
            .await
            .status,
        StatusCode::UNAUTHORIZED
    );
}