Prefer resource-specific scopes during MCP OAuth discovery (#54653)

## Why

Authorization server metadata can advertise scopes unrelated to the MCP resource. Using that list for discovery can request broader scopes than the resource requires.

## What changed

Update `rmcp` and use its initial scope selection during OAuth discovery: prefer scopes from the initial `WWW-Authenticate` challenge, fall back to protected resource metadata, and include `offline_access` when supported. Stop using the authorization server's general scope list as the discovered resource scopes.

## Testing

Add coverage for challenge scope precedence, resource metadata fallback, scope normalization, and exclusion of unrelated authorization server scopes. Add a no-browser login regression test for a preregistered client whose authorization server advertises neither scopes nor dynamic registration.

GitOrigin-RevId: 68d83cf61fb0b73dac9e861e476f484990fd93b6
This commit is contained in:
Alex Zamoshchin
2026-10-16 16:23:55 +00:00
committed by copyberry
parent fa46ef3af6
commit b26b0a7a77
6 changed files with 145 additions and 95 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -12947,7 +12947,7 @@ dependencies = [
[[package]]
name = "rmcp"
version = "3.5.1"
source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=4048ac40e958fb1a7d614617a2ae8d26666ee2fa#4048ac40e958fb1a7d614617a2ae8d26666ee2fa"
source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=98a54789b80110ad210495c4ad845c89e1980471#98a54789b80110ad210495c4ad845c89e1980471"
dependencies = [
"async-trait",
"base64 0.23.0",
@@ -12958,6 +12958,7 @@ dependencies = [
"http-body 1.0.1",
"http-body-util",
"indexmap 2.14.0",
"libc",
"oauth2",
"pastey",
"pin-project-lite",
@@ -12982,7 +12983,7 @@ dependencies = [
[[package]]
name = "rmcp-macros"
version = "3.5.1"
source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=4048ac40e958fb1a7d614617a2ae8d26666ee2fa#4048ac40e958fb1a7d614617a2ae8d26666ee2fa"
source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=98a54789b80110ad210495c4ad845c89e1980471#98a54789b80110ad210495c4ad845c89e1980471"
dependencies = [
"darling 0.24.0",
"proc-macro2",
+1 -1
View File
@@ -448,7 +448,7 @@ regex-automata = { version = "0.4.13", default-features = false, features = ["st
regex-lite = "0.1.8"
regex-syntax = "0.8.8"
reqwest = { version = "0.12", features = ["cookies"] }
rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", rev = "4048ac40e958fb1a7d614617a2ae8d26666ee2fa", version = "=3.5.1", default-features = false }
rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", rev = "98a54789b80110ad210495c4ad845c89e1980471", version = "=3.5.1", default-features = false }
runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" }
rustix = { version = "1.1.4", features = ["net"] }
rustls = { version = "0.23.46", default-features = false, features = [
+68 -5
View File
@@ -39,17 +39,26 @@ struct Fixture {
impl Fixture {
async fn new(client_secret: Option<&'static str>) -> Result<Self> {
Self::with_scopes(client_secret, &["mcp.read"]).await
}
async fn with_scopes(client_secret: Option<&'static str>, scopes: &[&str]) -> Result<Self> {
let server = MockServer::start().await;
let home = TempDir::new()?;
let issuer = format!("{}/mcp", server.uri());
let saved_client_secret = client_secret
.map(|secret| format!("client_secret = \"{secret}\"\n"))
.unwrap_or_default();
let saved_scopes = if scopes.is_empty() {
String::new()
} else {
format!("scopes = {scopes:?}\n")
};
std::fs::write(
home.path().join("config.toml"),
format!(
"mcp_oauth_credentials_store = \"file\"\n\
[mcp_servers.manual]\nurl = \"{issuer}\"\nscopes = [\"mcp.read\"]\n\
[mcp_servers.manual]\nurl = \"{issuer}\"\n{saved_scopes}\
[mcp_servers.manual.oauth]\nclient_id = \"registered-client\"\n\
{saved_client_secret}"
),
@@ -100,6 +109,30 @@ impl Fixture {
.await;
}
async fn mount_resource_metadata(&self, scopes: &[&str], challenge_scope: Option<&str>) {
let mut challenge = format!(
"Bearer resource_metadata=\"{}/resource-metadata\"",
self.server.uri()
);
if let Some(scope) = challenge_scope {
challenge.push_str(&format!(", scope=\"{scope}\""));
}
Mock::given(method("GET"))
.and(path("/mcp"))
.respond_with(ResponseTemplate::new(401).insert_header("www-authenticate", challenge))
.mount(&self.server)
.await;
Mock::given(method("GET"))
.and(path("/resource-metadata"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"resource": self.issuer,
"authorization_servers": [self.issuer],
"scopes_supported": scopes,
})))
.mount(&self.server)
.await;
}
async fn start(&self) -> Result<Login> {
let mut child = Command::new(codex_utils_cargo_bin::cargo_bin("codex")?)
.kill_on_drop(true)
@@ -199,6 +232,32 @@ async fn no_browser_exchanges_pasted_callback_and_saves_credentials() -> Result<
.await
}
#[tokio::test]
async fn no_browser_prefers_challenge_scopes_for_preregistered_client() -> Result<()> {
let fixture = Fixture::with_scopes(/*client_secret*/ None, &[]).await?;
fixture
.mount_resource_metadata(&["mcp.read", "mcp.write"], Some("mcp.read"))
.await;
// The authorization server advertises neither scopes nor dynamic registration.
Mock::given(method("GET"))
.and(path("/.well-known/oauth-authorization-server/mcp"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"issuer": fixture.issuer,
"authorization_endpoint": format!("{}/authorize", fixture.server.uri()),
"token_endpoint": format!("{}/token", fixture.server.uri()),
"response_types_supported": ["code"],
"code_challenge_methods_supported": ["S256"],
})))
.with_priority(/*priority*/ 1)
.mount(&fixture.server)
.await;
assert_no_browser_exchanges_pasted_callback_and_saves_credentials(
fixture, /*client_secret*/ None,
)
.await
}
#[tokio::test]
async fn no_browser_exchanges_pasted_callback_with_client_secret() -> Result<()> {
let fixture = Fixture::new(Some("headless-client-secret")).await?;
@@ -262,6 +321,9 @@ async fn add_persists_client_secret_for_subsequent_login_without_disclosure() ->
// Reset removes the priority override without relying on wiremock's scoped indices.
fixture.server.reset().await;
fixture.mount_oauth(Some(CLIENT_SECRET)).await;
fixture
.mount_resource_metadata(&["mcp.read"], /*challenge_scope*/ None)
.await;
assert_no_browser_exchanges_pasted_callback_and_saves_credentials(fixture, Some(CLIENT_SECRET))
.await
@@ -274,6 +336,7 @@ async fn assert_no_browser_exchanges_pasted_callback_and_saves_credentials(
let mut login = fixture.start().await?;
let authorization_url = login.authorization_url.to_string();
let params: BTreeMap<_, _> = login.authorization_url.query_pairs().into_owned().collect();
assert_eq!(params.get("scope").map(String::as_str), Some("mcp.read"));
let callback = login.callback(&fixture.issuer)?;
login
.child
@@ -438,10 +501,10 @@ async fn no_browser_accepts_http_callback_while_terminal_input_remains_open() ->
#[tokio::test]
async fn no_browser_retries_rejected_discovered_scopes_with_pasteback() -> Result<()> {
let fixture = Fixture::new(/*client_secret*/ None).await?;
let config_path = fixture.home.path().join("config.toml");
let config = std::fs::read_to_string(&config_path)?;
std::fs::write(config_path, config.replace("scopes = [\"mcp.read\"]\n", ""))?;
let fixture = Fixture::with_scopes(/*client_secret*/ None, &[]).await?;
fixture
.mount_resource_metadata(&["mcp.read"], /*challenge_scope*/ None)
.await;
let mut login = fixture.start().await?;
assert!(
login
+70 -84
View File
@@ -246,7 +246,7 @@ async fn discover_streamable_http_oauth_with_headers_and_http_client(
let mut authorization_manager =
AuthorizationManager::new_with_oauth_http_client(url, Arc::new(oauth_http_client)).await?;
authorization_manager.set_allow_missing_issuer(true);
discover_streamable_http_oauth_with_manager(&authorization_manager).await
discover_streamable_http_oauth_with_manager(&mut authorization_manager).await
}
fn has_configured_headers(
@@ -262,16 +262,21 @@ fn has_configured_headers(
}
async fn discover_streamable_http_oauth_with_manager(
authorization_manager: &AuthorizationManager,
authorization_manager: &mut AuthorizationManager,
) -> Result<Option<StreamableHttpOAuthDiscovery>> {
match authorization_manager.resolve_metadata().boxed().await {
Ok(resolution) if !resolution.source.is_discovered() => Ok(None),
Ok(resolution) => {
let metadata = resolution.metadata;
let callback_mode =
callback_mode(&metadata).unwrap_or(McpOAuthCallbackMode::CallbackSpecific);
authorization_manager.set_metadata(metadata);
Ok(Some(StreamableHttpOAuthDiscovery {
callback_mode: callback_mode(&metadata)
.unwrap_or(McpOAuthCallbackMode::CallbackSpecific),
scopes_supported: normalize_scopes(metadata.scopes_supported),
callback_mode,
// Prefer the initial challenge's scopes, falling back to resource metadata.
scopes_supported: normalize_scopes(
authorization_manager.select_initial_scopes(&[]).await,
),
}))
}
Err(AuthError::NoAuthorizationSupport) => Ok(None),
@@ -279,9 +284,7 @@ async fn discover_streamable_http_oauth_with_manager(
}
}
fn normalize_scopes(scopes_supported: Option<Vec<String>>) -> Option<Vec<String>> {
let scopes_supported = scopes_supported?;
fn normalize_scopes(scopes_supported: Vec<String>) -> Option<Vec<String>> {
let mut normalized = Vec::new();
for scope in scopes_supported {
let scope = scope.trim();
@@ -677,7 +680,7 @@ mod tests {
}
#[tokio::test]
async fn discover_streamable_http_oauth_returns_normalized_scopes() {
async fn discover_streamable_http_oauth_does_not_use_authorization_server_scopes() {
let server = spawn_oauth_discovery_server(serde_json::json!({
"authorization_endpoint": "https://example.com/authorize",
"token_endpoint": "https://example.com/token",
@@ -701,7 +704,7 @@ mod tests {
assert_eq!(
discovery,
StreamableHttpOAuthDiscovery {
scopes_supported: Some(vec!["profile".to_string(), "email".to_string()]),
scopes_supported: None,
callback_mode: McpOAuthCallbackMode::IssuerBound,
}
);
@@ -834,85 +837,68 @@ mod tests {
let authorization_server = spawn_oauth_discovery_server(serde_json::json!({
"authorization_endpoint": "https://example.com/authorize",
"token_endpoint": "https://example.com/token",
"scopes_supported": ["read", " write ", "read"],
"scopes_supported": ["unrelated:admin", "offline_access"],
}))
.await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let address = listener.local_addr().expect("listener should have address");
let resource_metadata_url = format!("http://{address}/oauth-resource");
let challenge = format!("Bearer resource_metadata=\"{resource_metadata_url}\"");
let authorization_server_url = authorization_server.url.clone();
let app = Router::new()
.route(
"/mcp",
get(move || {
let challenge = challenge.clone();
async move { (StatusCode::UNAUTHORIZED, [(WWW_AUTHENTICATE, challenge)]) }
}),
for (challenge_scope, expected_scopes) in [
("", vec!["read", "write", "offline_access"]),
(", scope=\"read\"", vec!["read", "offline_access"]),
] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let address = listener.local_addr().expect("listener should have address");
let resource_metadata_url = format!("http://{address}/oauth-resource");
let challenge =
format!("Bearer resource_metadata=\"{resource_metadata_url}\"{challenge_scope}");
let authorization_server_url = authorization_server.url.clone();
let app = Router::new()
.route(
"/mcp",
get(move || {
let challenge = challenge.clone();
async move { (StatusCode::UNAUTHORIZED, [(WWW_AUTHENTICATE, challenge)]) }
}),
)
.route(
"/oauth-resource",
get(move || {
let authorization_server_url = authorization_server_url.clone();
async move {
Json(serde_json::json!({
"resource": format!("http://{address}/mcp"),
"authorization_servers": [authorization_server_url],
"scopes_supported": ["read", " write ", "read", "", " "],
}))
}
}),
);
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.expect("server should run");
});
let resource_server = TestServer {
url: format!("http://{address}/mcp"),
handle,
};
let discovery = discover_streamable_http_oauth(
&resource_server.url,
/*http_headers*/ None,
/*env_http_headers*/ None,
test_http_client().await,
OAuthDiscoveryTimeout::LOCAL,
StreamableHttpRedirectMode::Legacy,
)
.route(
"/oauth-resource",
get(move || {
let authorization_server_url = authorization_server_url.clone();
async move {
Json(serde_json::json!({
"resource": format!("http://{address}/mcp"),
"authorization_servers": [authorization_server_url],
}))
}
}),
.await
.expect("discovery should succeed")
.expect("oauth support should be detected");
assert_eq!(
discovery.scopes_supported,
Some(expected_scopes.into_iter().map(str::to_string).collect())
);
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.expect("server should run");
});
let resource_server = TestServer {
url: format!("http://{address}/mcp"),
handle,
};
let discovery = discover_streamable_http_oauth(
&resource_server.url,
/*http_headers*/ None,
/*env_http_headers*/ None,
test_http_client().await,
OAuthDiscoveryTimeout::LOCAL,
StreamableHttpRedirectMode::Legacy,
)
.await
.expect("discovery should succeed")
.expect("oauth support should be detected");
assert_eq!(
discovery.scopes_supported,
Some(vec!["read".to_string(), "write".to_string()])
);
}
#[tokio::test]
async fn discover_streamable_http_oauth_ignores_empty_scopes() {
let server = spawn_oauth_discovery_server(serde_json::json!({
"authorization_endpoint": "https://example.com/authorize",
"token_endpoint": "https://example.com/token",
"scopes_supported": ["", " "],
}))
.await;
let discovery = discover_streamable_http_oauth(
&server.url,
/*http_headers*/ None,
/*env_http_headers*/ None,
test_http_client().await,
OAuthDiscoveryTimeout::LOCAL,
StreamableHttpRedirectMode::Legacy,
)
.await
.expect("discovery should succeed")
.expect("oauth support should be detected");
assert_eq!(discovery.scopes_supported, None);
}
}
#[tokio::test]
@@ -193,6 +193,7 @@ async fn assert_legacy_oauth_without_starting_an_mcp_session(
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"resource": resource_url,
"authorization_servers": [authorization_server.uri()],
"scopes_supported": ["mcp:read"],
})))
.expect(2)
.mount(&resource_server)
@@ -201,7 +202,6 @@ async fn assert_legacy_oauth_without_starting_an_mcp_session(
let mut metadata = json!({
"authorization_endpoint": format!("{}/authorize", authorization_server.uri()),
"token_endpoint": format!("{}/token", authorization_server.uri()),
"scopes_supported": ["mcp:read"],
"code_challenge_methods_supported": ["S256"],
});
match metadata_issuer {