diff --git a/dashboard/src/mcp.rs b/dashboard/src/mcp.rs index a6ec6068d48455f07de89e686e473e0dbe3755f8..c812de698507efcbc3546e7e7daa41a9713c729f 100644 --- a/dashboard/src/mcp.rs +++ b/dashboard/src/mcp.rs @@ -431,10 +431,11 @@ impl Store { return Err(fail("invalid_request")); } if !scopes.contains(&scopes_allowed[0]) - || scopes.iter().any(|s| !scopes_allowed.contains(s)) + || scopes.iter().any(|scope| !CATALOGS.iter().any(|(_, _, allowed)| allowed.contains(scope))) { return Err(fail("invalid_scope")); } + let scopes: Vec<_> = scopes.into_iter().filter(|scope| scopes_allowed.contains(scope)).collect(); let pending = secret(); put( &tx, @@ -1033,6 +1034,47 @@ mod tests { } } #[tokio::test] + async fn authorization_restricts_issuer_scopes_to_the_requested_resource() { + let fixture = Fixture::new(); + let uri = "http://127.0.0.1:57072/callback/H9rAWI-8eQbv"; + let client = value(fixture.store.oauth("register", &Method::POST, &HashMap::new(), &HeaderMap::new(), + json!({"client_name":"Codex","redirect_uris":[uri]})).unwrap()).await; + let mut request = fields(json!({"response_type":"code","client_id":client["client_id"],"state":"fixture-state", + "code_challenge":"AdJpfUsRa6XMg54f2khmgo2onMwePN3vmU9DOOoaTzo","code_challenge_method":"S256", + "redirect_uri":uri,"scope":"observability:read offline_access sessions:read sessions:write shale:read shale:write"})); + for (catalog, _, allowed) in CATALOGS { + request.insert("resource".into(), fixture.store.resource(catalog)); + let response = fixture.store.oauth("authorize", &Method::GET, &request, &HeaderMap::new(), Value::Null).unwrap(); + assert_eq!(response.status(), StatusCode::FOUND); + let pending_id = response.headers()["location"].to_str().unwrap().strip_prefix("/connect/").unwrap(); + let db = fixture.store.db.lock().unwrap(); + let mut pending = get(&db, &format!("pending:{}", hash(pending_id))).unwrap(); + let expected: Vec<_> = request["scope"].split_whitespace().filter(|scope| allowed.contains(scope)).collect(); + assert_eq!(pending["scopes"], json!(expected)); + assert_eq!(pending["redirect"], uri); + assert_eq!(pending["state"], "fixture-state"); + pending["id"] = json!(format!("fixture-{catalog}")); + put(&db, &format!("grant:fixture-{catalog}"), &pending, 0).unwrap(); + let tokens = fixture.store.issue(&db, &pending).unwrap(); + assert_eq!(tokens["scope"], expected.join(" ")); + assert!(tokens["refresh_token"].is_string()); + drop(db); + let headers = bearer(string(&tokens["access_token"])); + assert!(fixture.store.authenticate(&headers, &fixture.store.resource(catalog)).is_ok()); + for (other, _, _) in CATALOGS.iter().filter(|(other, _, _)| other != catalog) { + assert!(fixture.store.authenticate(&headers, &fixture.store.resource(other)).is_err()); + } + } + for scopes in ["shale:read unknown:scope", "observability:read offline_access", "offline_access", ""] { + request.insert("resource".into(), fixture.store.resource("shale")); + request.insert("scope".into(), scopes.into()); + assert_eq!(fixture.store.oauth("authorize", &Method::GET, &request, &HeaderMap::new(), Value::Null).unwrap_err().message, "invalid_scope"); + } + request.insert("scope".into(), "shale:read".into()); + request.insert("redirect_uri".into(), uri.replace("H9rAWI-8eQbv", "different")); + assert_eq!(fixture.store.oauth("authorize", &Method::GET, &request, &HeaderMap::new(), Value::Null).unwrap_err().message, "invalid_redirect_uri"); + } + #[tokio::test] async fn code_binds_pkce_client_redirect_and_audience_without_consuming_on_failure() { let fixture = Fixture::new(); let input = fixture.code("one", "owned-code");