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
2026-08-25 20:26:23 +00:00
committed by copyberry
parent 32ce703a80
commit 9be8d6e1c3
28 changed files with 1096 additions and 127 deletions
@@ -28,6 +28,7 @@ use codex_core_plugins::remote_bundle::RemotePluginBundleInstallError;
use codex_mcp::McpOAuthLoginSupport;
use codex_mcp::McpRuntimeContext;
use codex_mcp::oauth_login_support;
use codex_mcp::resolve_oauth_callback;
use codex_mcp::should_retry_without_scopes;
use codex_plugin::PluginId;
use codex_plugin::PluginTelemetryMetadata;
@@ -1943,12 +1944,25 @@ impl PluginRequestProcessor {
let store_mode = config.mcp_oauth_credentials_store_mode;
let keyring_backend_kind = config.auth_keyring_backend_kind();
let callback_port = server.oauth_callback_port(config.mcp_oauth_callback_port);
let callback_url = config.mcp_oauth_callback_url.clone();
let callback_url = match resolve_oauth_callback(
&server,
&oauth_config.url,
config.mcp_oauth_callback_url.as_deref(),
) {
Ok(callback_url) => callback_url,
Err(error) => {
warn!(
"failed to resolve MCP OAuth callback for plugin install {name}: {error}"
);
continue;
}
};
let outgoing = Arc::clone(&self.outgoing);
let notification_name = name.clone();
let oauth_credential_name = server.oauth_credential_name(&name).into_owned();
let thread_manager = Arc::clone(&self.thread_manager);
let http_client = Arc::clone(&http_client);
let global_callback_url = config.mcp_oauth_callback_url.clone();
tokio::spawn(async move {
let oauth_client_id = server.oauth_client_id();
@@ -1965,6 +1979,7 @@ impl PluginRequestProcessor {
server.oauth_resource.as_deref(),
callback_port,
callback_url.as_deref(),
global_callback_url.as_deref(),
Arc::clone(&http_client),
redirect_mode,
)
@@ -1985,6 +2000,7 @@ impl PluginRequestProcessor {
server.oauth_resource.as_deref(),
callback_port,
callback_url.as_deref(),
global_callback_url.as_deref(),
http_client,
redirect_mode,
)