authorclover caruso <git@paperclover.net> 2026-10-08 10:54:44-07:00
committerclover caruso <git@paperclover.net> 2026-10-09 11:21:14-07:00
logcd1feb2dc61200228c20886221a5db9fda0c8f21
tree73415315de63962d1d7848b9764f93da609572b7
parent0122950a0992d4acf7242ee3f0a31d8bdb87b1e1
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

Limit issuer-wide OAuth scope requests to the selected MCP resource


1 files changed, 43 insertions(+), 1 deletions(-)

dashboard/src/mcp.rs+43-1
......@@ -431,10 +431,11 @@ impl Store {
431431 return Err(fail("invalid_request"));
432432 }
433433 if !scopes.contains(&scopes_allowed[0])
434 || scopes.iter().any(|s| !scopes_allowed.contains(s))
434 || scopes.iter().any(|scope| !CATALOGS.iter().any(|(_, _, allowed)| allowed.contains(scope)))
435435 {
436436 return Err(fail("invalid_scope"));
437437 }
438 let scopes: Vec<_> = scopes.into_iter().filter(|scope| scopes_allowed.contains(scope)).collect();
438439 let pending = secret();
439440 put(
440441 &tx,
......@@ -1033,6 +1034,47 @@ mod tests {
10331034 }
10341035 }
10351036 #[tokio::test]
1037 async fn authorization_restricts_issuer_scopes_to_the_requested_resource() {
1038 let fixture = Fixture::new();
1039 let uri = "http://127.0.0.1:57072/callback/H9rAWI-8eQbv";
1040 let client = value(fixture.store.oauth("register", &Method::POST, &HashMap::new(), &HeaderMap::new(),
1041 json!({"client_name":"Codex","redirect_uris":[uri]})).unwrap()).await;
1042 let mut request = fields(json!({"response_type":"code","client_id":client["client_id"],"state":"fixture-state",
1043 "code_challenge":"AdJpfUsRa6XMg54f2khmgo2onMwePN3vmU9DOOoaTzo","code_challenge_method":"S256",
1044 "redirect_uri":uri,"scope":"observability:read offline_access sessions:read sessions:write shale:read shale:write"}));
1045 for (catalog, _, allowed) in CATALOGS {
1046 request.insert("resource".into(), fixture.store.resource(catalog));
1047 let response = fixture.store.oauth("authorize", &Method::GET, &request, &HeaderMap::new(), Value::Null).unwrap();
1048 assert_eq!(response.status(), StatusCode::FOUND);
1049 let pending_id = response.headers()["location"].to_str().unwrap().strip_prefix("/connect/").unwrap();
1050 let db = fixture.store.db.lock().unwrap();
1051 let mut pending = get(&db, &format!("pending:{}", hash(pending_id))).unwrap();
1052 let expected: Vec<_> = request["scope"].split_whitespace().filter(|scope| allowed.contains(scope)).collect();
1053 assert_eq!(pending["scopes"], json!(expected));
1054 assert_eq!(pending["redirect"], uri);
1055 assert_eq!(pending["state"], "fixture-state");
1056 pending["id"] = json!(format!("fixture-{catalog}"));
1057 put(&db, &format!("grant:fixture-{catalog}"), &pending, 0).unwrap();
1058 let tokens = fixture.store.issue(&db, &pending).unwrap();
1059 assert_eq!(tokens["scope"], expected.join(" "));
1060 assert!(tokens["refresh_token"].is_string());
1061 drop(db);
1062 let headers = bearer(string(&tokens["access_token"]));
1063 assert!(fixture.store.authenticate(&headers, &fixture.store.resource(catalog)).is_ok());
1064 for (other, _, _) in CATALOGS.iter().filter(|(other, _, _)| other != catalog) {
1065 assert!(fixture.store.authenticate(&headers, &fixture.store.resource(other)).is_err());
1066 }
1067 }
1068 for scopes in ["shale:read unknown:scope", "observability:read offline_access", "offline_access", ""] {
1069 request.insert("resource".into(), fixture.store.resource("shale"));
1070 request.insert("scope".into(), scopes.into());
1071 assert_eq!(fixture.store.oauth("authorize", &Method::GET, &request, &HeaderMap::new(), Value::Null).unwrap_err().message, "invalid_scope");
1072 }
1073 request.insert("scope".into(), "shale:read".into());
1074 request.insert("redirect_uri".into(), uri.replace("H9rAWI-8eQbv", "different"));
1075 assert_eq!(fixture.store.oauth("authorize", &Method::GET, &request, &HeaderMap::new(), Value::Null).unwrap_err().message, "invalid_redirect_uri");
1076 }
1077 #[tokio::test]
10361078 async fn code_binds_pkce_client_redirect_and_audience_without_consuming_on_failure() {
10371079 let fixture = Fixture::new();
10381080 let input = fixture.code("one", "owned-code");