mirror of
https://github.com/openai/codex.git
synced 2026-10-19 08:16:48 +00:00
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:
committed by
copyberry
parent
fa46ef3af6
commit
b26b0a7a77
Generated
+2
-2
File diff suppressed because one or more lines are too long
Generated
+3
-2
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user