use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use anyhow::Result; use codex_exec_server::HttpClient; use codex_protocol::protocol::McpAuthStatus; use futures::FutureExt; use reqwest::Client; use reqwest::header::AUTHORIZATION; use reqwest::header::HeaderMap; use rmcp::transport::AuthorizationManager; use rmcp::transport::auth::AuthError; use tracing::debug; use crate::oauth::StoredOAuthTokenStatus; use crate::oauth::oauth_token_status; use crate::oauth_http_client::OAuthHttpClientAdapter; use crate::utils::apply_default_headers; use crate::utils::build_default_headers; use codex_config::types::AuthKeyringBackendKind; use codex_config::types::OAuthCredentialsStoreMode; const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); #[derive(Debug, Clone, PartialEq, Eq)] pub struct StreamableHttpOAuthDiscovery { pub scopes_supported: Option>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum McpLoginRequirement { Login, Reauthentication, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum McpAuthState { Unsupported, LoggedOut(McpLoginRequirement), BearerToken, OAuth, } impl From for McpAuthStatus { fn from(value: McpAuthState) -> Self { match value { McpAuthState::Unsupported => Self::Unsupported, McpAuthState::LoggedOut(_) => Self::NotLoggedIn, McpAuthState::BearerToken => Self::BearerToken, McpAuthState::OAuth => Self::OAuth, } } } enum AuthStatusCheck { Complete(McpAuthState), Discover(HeaderMap), } /// Determine the authentication status for a streamable HTTP MCP server. pub async fn determine_streamable_http_auth_status( server_name: &str, url: &str, bearer_token_env_var: Option<&str>, http_headers: Option>, env_http_headers: Option>, store_mode: OAuthCredentialsStoreMode, keyring_backend_kind: AuthKeyringBackendKind, ) -> Result { let default_headers = match auth_status_before_discovery( server_name, url, bearer_token_env_var, http_headers, env_http_headers, store_mode, keyring_backend_kind, )? { AuthStatusCheck::Complete(status) => return Ok(status), AuthStatusCheck::Discover(default_headers) => default_headers, }; determine_auth_status_from_discovery( server_name, url, discover_streamable_http_oauth_with_headers(url, &default_headers).await, ) } /// Determine authentication status while routing OAuth discovery through the /// provided HTTP client. #[allow(clippy::too_many_arguments)] pub async fn determine_streamable_http_auth_status_with_http_client( server_name: &str, url: &str, bearer_token_env_var: Option<&str>, http_headers: Option>, env_http_headers: Option>, store_mode: OAuthCredentialsStoreMode, keyring_backend_kind: AuthKeyringBackendKind, http_client: Arc, ) -> Result { let default_headers = match auth_status_before_discovery( server_name, url, bearer_token_env_var, http_headers, env_http_headers, store_mode, keyring_backend_kind, )? { AuthStatusCheck::Complete(status) => return Ok(status), AuthStatusCheck::Discover(default_headers) => default_headers, }; determine_auth_status_from_discovery( server_name, url, discover_streamable_http_oauth_with_headers_and_http_client( url, default_headers, http_client, ) .await, ) } fn auth_status_before_discovery( server_name: &str, url: &str, bearer_token_env_var: Option<&str>, http_headers: Option>, env_http_headers: Option>, store_mode: OAuthCredentialsStoreMode, keyring_backend_kind: AuthKeyringBackendKind, ) -> Result { if bearer_token_env_var.is_some() { return Ok(AuthStatusCheck::Complete(McpAuthState::BearerToken)); } let default_headers = build_default_headers(http_headers, env_http_headers)?; if default_headers.contains_key(AUTHORIZATION) { return Ok(AuthStatusCheck::Complete(McpAuthState::BearerToken)); } match oauth_token_status(server_name, url, store_mode, keyring_backend_kind)? { StoredOAuthTokenStatus::Usable => { return Ok(AuthStatusCheck::Complete(McpAuthState::OAuth)); } StoredOAuthTokenStatus::AuthorizationRequired => { return Ok(AuthStatusCheck::Complete(McpAuthState::LoggedOut( McpLoginRequirement::Reauthentication, ))); } StoredOAuthTokenStatus::Missing => {} } Ok(AuthStatusCheck::Discover(default_headers)) } fn determine_auth_status_from_discovery( server_name: &str, url: &str, discovery: Result>, ) -> Result { match discovery { Ok(Some(_)) => Ok(McpAuthState::LoggedOut(McpLoginRequirement::Login)), Ok(None) => Ok(McpAuthState::Unsupported), Err(error) => { debug!( "failed to detect OAuth support for MCP server `{server_name}` at {url}: {error:?}" ); Ok(McpAuthState::Unsupported) } } } /// Attempt to determine whether a streamable HTTP MCP server advertises OAuth login. pub async fn supports_oauth_login(url: &str) -> Result { Ok(discover_streamable_http_oauth( url, /*http_headers*/ None, /*env_http_headers*/ None, ) .await? .is_some()) } pub async fn discover_streamable_http_oauth( url: &str, http_headers: Option>, env_http_headers: Option>, ) -> Result> { let default_headers = build_default_headers(http_headers, env_http_headers)?; discover_streamable_http_oauth_with_headers(url, &default_headers).await } pub async fn discover_streamable_http_oauth_with_http_client( url: &str, http_headers: Option>, env_http_headers: Option>, http_client: Arc, ) -> Result> { let default_headers = build_default_headers(http_headers, env_http_headers)?; discover_streamable_http_oauth_with_headers_and_http_client(url, default_headers, http_client) .await } async fn discover_streamable_http_oauth_with_headers( url: &str, default_headers: &HeaderMap, ) -> Result> { // Use no_proxy to avoid a bug in the system-configuration crate that // can result in a panic. See #8912. let builder = Client::builder().timeout(DISCOVERY_TIMEOUT).no_proxy(); let client = apply_default_headers(builder, default_headers).build()?; let mut authorization_manager = AuthorizationManager::new(url).await?; authorization_manager.with_client(client)?; discover_streamable_http_oauth_with_manager(&authorization_manager).await } async fn discover_streamable_http_oauth_with_headers_and_http_client( url: &str, default_headers: HeaderMap, http_client: Arc, ) -> Result> { let authorization_manager = AuthorizationManager::new_with_oauth_http_client( url, Arc::new(OAuthHttpClientAdapter::new(http_client, default_headers)), ) .await?; discover_streamable_http_oauth_with_manager(&authorization_manager).await } async fn discover_streamable_http_oauth_with_manager( authorization_manager: &AuthorizationManager, ) -> Result> { match authorization_manager.discover_metadata().boxed().await { Ok(metadata) => Ok(Some(StreamableHttpOAuthDiscovery { scopes_supported: normalize_scopes(metadata.scopes_supported), })), Err(AuthError::NoAuthorizationSupport) => Ok(None), Err(err) => Err(err.into()), } } fn normalize_scopes(scopes_supported: Option>) -> Option> { let scopes_supported = scopes_supported?; let mut normalized = Vec::new(); for scope in scopes_supported { let scope = scope.trim(); if scope.is_empty() { continue; } let scope = scope.to_string(); if !normalized.contains(&scope) { normalized.push(scope); } } if normalized.is_empty() { None } else { Some(normalized) } } #[cfg(test)] mod tests { use super::*; use axum::Json; use axum::Router; use axum::http::StatusCode; use axum::http::header::WWW_AUTHENTICATE; use axum::routing::get; use pretty_assertions::assert_eq; use serial_test::serial; use std::collections::HashMap; use std::ffi::OsString; use tokio::task::JoinHandle; struct TestServer { url: String, handle: JoinHandle<()>, } impl Drop for TestServer { fn drop(&mut self) { self.handle.abort(); } } async fn spawn_oauth_discovery_server(metadata: serde_json::Value) -> TestServer { 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 app = Router::new().route( "/.well-known/oauth-authorization-server/mcp", get({ let metadata = metadata.clone(); move || { let metadata = metadata.clone(); async move { Json(metadata) } } }), ); let handle = tokio::spawn(async move { axum::serve(listener, app).await.expect("server should run"); }); TestServer { url: format!("http://{address}/mcp"), handle, } } struct EnvVarGuard { key: String, original: Option, } impl EnvVarGuard { fn set(key: &str, value: &str) -> Self { let original = std::env::var_os(key); unsafe { std::env::set_var(key, value); } Self { key: key.to_string(), original, } } } impl Drop for EnvVarGuard { fn drop(&mut self) { if let Some(value) = &self.original { unsafe { std::env::set_var(&self.key, value); } } else { unsafe { std::env::remove_var(&self.key); } } } } #[tokio::test] async fn determine_auth_status_uses_bearer_token_when_authorization_header_present() { let status = determine_streamable_http_auth_status( "server", "not-a-url", /*bearer_token_env_var*/ None, Some(HashMap::from([( "Authorization".to_string(), "Bearer token".to_string(), )])), /*env_http_headers*/ None, OAuthCredentialsStoreMode::Keyring, AuthKeyringBackendKind::default(), ) .await .expect("status should compute"); assert_eq!(status, McpAuthState::BearerToken); } #[tokio::test] #[serial(auth_status_env)] async fn determine_auth_status_uses_bearer_token_when_env_authorization_header_present() { let _guard = EnvVarGuard::set("CODEX_RMCP_CLIENT_AUTH_STATUS_TEST_TOKEN", "Bearer token"); let status = determine_streamable_http_auth_status( "server", "not-a-url", /*bearer_token_env_var*/ None, /*http_headers*/ None, Some(HashMap::from([( "Authorization".to_string(), "CODEX_RMCP_CLIENT_AUTH_STATUS_TEST_TOKEN".to_string(), )])), OAuthCredentialsStoreMode::Keyring, AuthKeyringBackendKind::default(), ) .await .expect("status should compute"); assert_eq!(status, McpAuthState::BearerToken); } #[tokio::test] async fn discover_streamable_http_oauth_returns_normalized_scopes() { let server = spawn_oauth_discovery_server(serde_json::json!({ "authorization_endpoint": "https://example.com/authorize", "token_endpoint": "https://example.com/token", "scopes_supported": ["profile", " email ", "profile", "", " "], })) .await; let discovery = discover_streamable_http_oauth( &server.url, /*http_headers*/ None, /*env_http_headers*/ None, ) .await .expect("discovery should succeed") .expect("oauth support should be detected"); assert_eq!( discovery.scopes_supported, Some(vec!["profile".to_string(), "email".to_string()]) ); } #[tokio::test] async fn discover_streamable_http_oauth_follows_protected_resource_metadata() { 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"], })) .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)]) } }), ) .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], })) } }), ); 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, ) .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, ) .await .expect("discovery should succeed") .expect("oauth support should be detected"); assert_eq!(discovery.scopes_supported, None); } #[tokio::test] async fn supports_oauth_login_does_not_require_scopes_supported() { let server = spawn_oauth_discovery_server(serde_json::json!({ "authorization_endpoint": "https://example.com/authorize", "token_endpoint": "https://example.com/token", })) .await; let supported = supports_oauth_login(&server.url) .await .expect("support check should succeed"); assert!(supported); } }