Harden MCP OAuth callback handling (#40691)

## Why

MCP servers can share an OAuth callback URL. Without a validated issuer or a
server-specific callback path, an authorization response could be associated
with the wrong server.

## What changed

- Use stable callbacks when authorization metadata advertises issuer-bound
  responses, and validate the returned issuer before exchanging the code.
- Retain server-specific callback IDs for providers without issuer support,
  including fallback to the global or default callback for legacy registered
  clients.
- Persist registered callback URLs for MCP servers and plugins, and insert the
  active listener port into portless loopback redirects.

## Testing

Add coverage for issuer validation, callback-mode discovery, registered and
legacy clients, plugin OAuth, CLI persistence, and loopback listener ports.

GitOrigin-RevId: 2878c92e237fc17fd3def0bd2e1cce3e104a3db8
This commit is contained in:
stevenlee-oai authored and copyberry committed 2026-08-25 20:26:23 +00:00
1 parent 32ce703a80
commit 9be8d6e1c3
28 files changed
+1096 -127

No files matched your search

+69 -1
View File
@@ -342,10 +342,13 @@ async fn add_streamable_http_with_custom_env_var() -> Result<()> {
#[tokio::test]
async fn add_streamable_http_with_oauth_options() -> Result<()> {
let codex_home = TempDir::new()?;
let expected_callback = "http://127.0.0.1/callback/w9gKTtkB7gWy";
let mut add_cmd = codex_command(codex_home.path())?;
add_cmd
.args([
"-c",
"mcp_oauth_callback_port=43123",
"mcp",
"add",
"oauth-server",
@@ -357,7 +360,8 @@ async fn add_streamable_http_with_oauth_options() -> Result<()> {
"https://resource.example.com",
])
.assert()
.success();
.success()
.stdout(contains(format!("OAuth callback URL: {expected_callback}")));
let servers = load_global_mcp_servers(codex_home.path()).await?;
let oauth_server = servers
@@ -367,6 +371,13 @@ async fn add_streamable_http_with_oauth_options() -> Result<()> {
oauth_server.oauth_client_id(),
Some("eci-prd-pub-codex-123")
);
assert_eq!(
oauth_server
.oauth
.as_ref()
.and_then(|oauth| oauth.callback_url.as_deref()),
Some(expected_callback)
);
assert_eq!(
oauth_server.oauth_resource.as_deref(),
Some("https://resource.example.com")
@@ -375,6 +386,63 @@ async fn add_streamable_http_with_oauth_options() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn add_persists_issuer_bound_callback_before_starting_oauth() -> Result<()> {
let codex_home = TempDir::new()?;
let oauth_server = MockServer::start().await;
let issuer = format!("{}/mcp", oauth_server.uri());
let metadata = serde_json::json!({
"issuer": issuer,
"authorization_endpoint": "not-a-valid-authorization-url",
"token_endpoint": format!("{}/token", oauth_server.uri()),
"authorization_response_iss_parameter_supported": true,
});
let concurrent_codex_home = codex_home.path().to_path_buf();
let concurrent_add = std::sync::Once::new();
Mock::given(method("GET"))
.and(path("/.well-known/oauth-authorization-server/mcp"))
.respond_with(move |_: &wiremock::Request| {
concurrent_add.call_once(|| {
codex_command(&concurrent_codex_home)
.expect("create concurrent MCP add command")
.args(["mcp", "add", "concurrent", "--", "echo", "concurrent"])
.assert()
.success();
});
ResponseTemplate::new(200).set_body_json(metadata.clone())
})
.mount(&oauth_server)
.await;
let mut add_cmd = codex_command(codex_home.path())?;
add_cmd.args([
"mcp",
"add",
"issuer-bound",
"--url",
&format!("{}/mcp", oauth_server.uri()),
"--oauth-client-id",
"registered-client",
]);
let output = tokio::task::spawn_blocking(move || add_cmd.output()).await??;
assert!(!output.status.success());
assert!(
String::from_utf8(output.stdout)?.contains("OAuth callback URL: http://127.0.0.1/callback")
);
let servers = load_global_mcp_servers(codex_home.path()).await?;
assert!(servers.contains_key("concurrent"));
assert_eq!(
servers["issuer-bound"]
.oauth
.as_ref()
.and_then(|oauth| oauth.callback_url.as_deref()),
Some("http://127.0.0.1/callback")
);
Ok(())
}
#[tokio::test]
async fn add_streamable_http_rejects_removed_flag() -> Result<()> {
let codex_home = TempDir::new()?;