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