Skip to main content

codex_mcp/mcp/
auth.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use anyhow::Result;
5use codex_config::McpServerAuth;
6use codex_config::McpServerConfig;
7use codex_config::McpServerTransportConfig;
8use codex_config::types::AuthKeyringBackendKind;
9use codex_config::types::OAuthCredentialsStoreMode;
10use codex_exec_server::HttpClient;
11use codex_login::CodexAuth;
12use codex_rmcp_client::McpAuthState;
13use codex_rmcp_client::OAuthProviderError;
14use codex_rmcp_client::determine_streamable_http_auth_status;
15use codex_rmcp_client::determine_streamable_http_auth_status_with_http_client;
16use codex_rmcp_client::discover_streamable_http_oauth;
17use codex_rmcp_client::discover_streamable_http_oauth_with_http_client;
18use futures::FutureExt;
19use futures::future::join_all;
20use tracing::warn;
21
22use crate::runtime::McpRuntimeContext;
23use crate::server::EffectiveMcpServer;
24
25#[derive(Debug, Clone)]
26pub struct McpOAuthLoginConfig {
27    pub url: String,
28    pub http_headers: Option<HashMap<String, String>>,
29    pub env_http_headers: Option<HashMap<String, String>>,
30    pub discovered_scopes: Option<Vec<String>>,
31}
32
33#[derive(Debug)]
34pub enum McpOAuthLoginSupport {
35    Supported(McpOAuthLoginConfig),
36    Unsupported,
37    Unknown(anyhow::Error),
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum McpOAuthScopesSource {
42    Explicit,
43    Configured,
44    Discovered,
45    Empty,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ResolvedMcpOAuthScopes {
50    pub scopes: Vec<String>,
51    pub source: McpOAuthScopesSource,
52}
53
54#[derive(Debug, Clone)]
55pub struct McpAuthStatusEntry {
56    pub config: Option<McpServerConfig>,
57    pub auth_state: McpAuthState,
58}
59
60pub async fn oauth_login_support(transport: &McpServerTransportConfig) -> McpOAuthLoginSupport {
61    let Some(mut config) = oauth_login_candidate(transport) else {
62        return McpOAuthLoginSupport::Unsupported;
63    };
64    match discover_streamable_http_oauth(
65        &config.url,
66        config.http_headers.clone(),
67        config.env_http_headers.clone(),
68    )
69    .await
70    {
71        Ok(Some(discovery)) => {
72            config.discovered_scopes = discovery.scopes_supported;
73            McpOAuthLoginSupport::Supported(config)
74        }
75        Ok(None) => McpOAuthLoginSupport::Unsupported,
76        Err(err) => McpOAuthLoginSupport::Unknown(err),
77    }
78}
79
80pub async fn oauth_login_support_with_http_client(
81    transport: &McpServerTransportConfig,
82    http_client: Arc<dyn HttpClient>,
83) -> McpOAuthLoginSupport {
84    let Some(mut config) = oauth_login_candidate(transport) else {
85        return McpOAuthLoginSupport::Unsupported;
86    };
87    match discover_streamable_http_oauth_with_http_client(
88        &config.url,
89        config.http_headers.clone(),
90        config.env_http_headers.clone(),
91        http_client,
92    )
93    .await
94    {
95        Ok(Some(discovery)) => {
96            config.discovered_scopes = discovery.scopes_supported;
97            McpOAuthLoginSupport::Supported(config)
98        }
99        Ok(None) => McpOAuthLoginSupport::Unsupported,
100        Err(err) => McpOAuthLoginSupport::Unknown(err),
101    }
102}
103
104fn oauth_login_candidate(transport: &McpServerTransportConfig) -> Option<McpOAuthLoginConfig> {
105    let McpServerTransportConfig::StreamableHttp {
106        url,
107        bearer_token_env_var,
108        http_headers,
109        env_http_headers,
110    } = transport
111    else {
112        return None;
113    };
114    if bearer_token_env_var.is_some() {
115        return None;
116    }
117    Some(McpOAuthLoginConfig {
118        url: url.clone(),
119        http_headers: http_headers.clone(),
120        env_http_headers: env_http_headers.clone(),
121        discovered_scopes: None,
122    })
123}
124
125pub async fn discover_supported_scopes(
126    transport: &McpServerTransportConfig,
127) -> Option<Vec<String>> {
128    match oauth_login_support(transport).await {
129        McpOAuthLoginSupport::Supported(config) => config.discovered_scopes,
130        McpOAuthLoginSupport::Unsupported | McpOAuthLoginSupport::Unknown(_) => None,
131    }
132}
133
134pub async fn discover_supported_scopes_with_http_client(
135    transport: &McpServerTransportConfig,
136    http_client: Arc<dyn HttpClient>,
137) -> Option<Vec<String>> {
138    match oauth_login_support_with_http_client(transport, http_client).await {
139        McpOAuthLoginSupport::Supported(config) => config.discovered_scopes,
140        McpOAuthLoginSupport::Unsupported | McpOAuthLoginSupport::Unknown(_) => None,
141    }
142}
143
144pub fn resolve_oauth_scopes(
145    explicit_scopes: Option<Vec<String>>,
146    configured_scopes: Option<Vec<String>>,
147    discovered_scopes: Option<Vec<String>>,
148) -> ResolvedMcpOAuthScopes {
149    if let Some(scopes) = explicit_scopes {
150        return ResolvedMcpOAuthScopes {
151            scopes,
152            source: McpOAuthScopesSource::Explicit,
153        };
154    }
155
156    if let Some(scopes) = configured_scopes {
157        return ResolvedMcpOAuthScopes {
158            scopes,
159            source: McpOAuthScopesSource::Configured,
160        };
161    }
162
163    if let Some(scopes) = discovered_scopes
164        && !scopes.is_empty()
165    {
166        return ResolvedMcpOAuthScopes {
167            scopes,
168            source: McpOAuthScopesSource::Discovered,
169        };
170    }
171
172    ResolvedMcpOAuthScopes {
173        scopes: Vec::new(),
174        source: McpOAuthScopesSource::Empty,
175    }
176}
177
178pub fn should_retry_without_scopes(scopes: &ResolvedMcpOAuthScopes, error: &anyhow::Error) -> bool {
179    scopes.source == McpOAuthScopesSource::Discovered
180        && error.downcast_ref::<OAuthProviderError>().is_some()
181}
182
183pub async fn compute_auth_statuses<'a, I>(
184    servers: I,
185    store_mode: OAuthCredentialsStoreMode,
186    keyring_backend_kind: AuthKeyringBackendKind,
187    auth: Option<&CodexAuth>,
188    runtime_context: &McpRuntimeContext,
189) -> HashMap<String, McpAuthStatusEntry>
190where
191    I: IntoIterator<Item = (&'a String, &'a EffectiveMcpServer)>,
192{
193    let futures = servers.into_iter().map(|(name, server)| {
194        let name = name.clone();
195        let config = server.configured_config().cloned();
196        let runtime_context = runtime_context.clone();
197        let has_runtime_auth = config
198            .as_ref()
199            .is_some_and(|config| matches!(&config.auth, McpServerAuth::ChatGpt))
200            && auth.is_some_and(CodexAuth::uses_codex_backend)
201            && config.as_ref().is_some_and(|config| {
202                matches!(
203                    &config.transport,
204                    McpServerTransportConfig::StreamableHttp {
205                        bearer_token_env_var: None,
206                        ..
207                    }
208                )
209            });
210        async move {
211            let auth_state = match config.as_ref() {
212                Some(config) => {
213                    match compute_auth_status(
214                        &name,
215                        config,
216                        store_mode,
217                        keyring_backend_kind,
218                        has_runtime_auth,
219                        &runtime_context,
220                    )
221                    .await
222                    {
223                        Ok(status) => status,
224                        Err(error) => {
225                            warn!(
226                                "failed to determine auth status for MCP server `{name}`: {error:?}"
227                            );
228                            McpAuthState::Unsupported
229                        }
230                    }
231                }
232                None => McpAuthState::Unsupported,
233            };
234            let entry = McpAuthStatusEntry { config, auth_state };
235            (name, entry)
236        }
237    });
238
239    join_all(futures).await.into_iter().collect()
240}
241
242async fn compute_auth_status(
243    server_name: &str,
244    config: &McpServerConfig,
245    store_mode: OAuthCredentialsStoreMode,
246    keyring_backend_kind: AuthKeyringBackendKind,
247    has_runtime_auth: bool,
248    runtime_context: &McpRuntimeContext,
249) -> Result<McpAuthState> {
250    if !config.enabled {
251        return Ok(McpAuthState::Unsupported);
252    }
253
254    if has_runtime_auth {
255        return Ok(McpAuthState::BearerToken);
256    }
257
258    match &config.transport {
259        McpServerTransportConfig::Stdio { .. } => Ok(McpAuthState::Unsupported),
260        McpServerTransportConfig::StreamableHttp {
261            url,
262            bearer_token_env_var,
263            http_headers,
264            env_http_headers,
265        } => {
266            if config.is_local_environment() {
267                determine_streamable_http_auth_status(
268                    server_name,
269                    url,
270                    bearer_token_env_var.as_deref(),
271                    http_headers.clone(),
272                    env_http_headers.clone(),
273                    store_mode,
274                    keyring_backend_kind,
275                )
276                .boxed()
277                .await
278            } else {
279                let http_client = runtime_context
280                    .resolve_http_client(server_name, config)
281                    .map_err(anyhow::Error::msg)?;
282                determine_streamable_http_auth_status_with_http_client(
283                    server_name,
284                    url,
285                    bearer_token_env_var.as_deref(),
286                    http_headers.clone(),
287                    env_http_headers.clone(),
288                    store_mode,
289                    keyring_backend_kind,
290                    http_client,
291                )
292                .boxed()
293                .await
294            }
295        }
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use anyhow::anyhow;
302    use pretty_assertions::assert_eq;
303
304    use super::McpOAuthScopesSource;
305    use super::OAuthProviderError;
306    use super::ResolvedMcpOAuthScopes;
307    use super::resolve_oauth_scopes;
308    use super::should_retry_without_scopes;
309
310    #[test]
311    fn resolve_oauth_scopes_prefers_explicit() {
312        let resolved = resolve_oauth_scopes(
313            Some(vec!["explicit".to_string()]),
314            Some(vec!["configured".to_string()]),
315            Some(vec!["discovered".to_string()]),
316        );
317
318        assert_eq!(
319            resolved,
320            ResolvedMcpOAuthScopes {
321                scopes: vec!["explicit".to_string()],
322                source: McpOAuthScopesSource::Explicit,
323            }
324        );
325    }
326
327    #[test]
328    fn resolve_oauth_scopes_prefers_configured_over_discovered() {
329        let resolved = resolve_oauth_scopes(
330            /*explicit_scopes*/ None,
331            Some(vec!["configured".to_string()]),
332            Some(vec!["discovered".to_string()]),
333        );
334
335        assert_eq!(
336            resolved,
337            ResolvedMcpOAuthScopes {
338                scopes: vec!["configured".to_string()],
339                source: McpOAuthScopesSource::Configured,
340            }
341        );
342    }
343
344    #[test]
345    fn resolve_oauth_scopes_uses_discovered_when_needed() {
346        let resolved = resolve_oauth_scopes(
347            /*explicit_scopes*/ None,
348            /*configured_scopes*/ None,
349            Some(vec!["discovered".to_string()]),
350        );
351
352        assert_eq!(
353            resolved,
354            ResolvedMcpOAuthScopes {
355                scopes: vec!["discovered".to_string()],
356                source: McpOAuthScopesSource::Discovered,
357            }
358        );
359    }
360
361    #[test]
362    fn resolve_oauth_scopes_preserves_explicitly_empty_configured_scopes() {
363        let resolved = resolve_oauth_scopes(
364            /*explicit_scopes*/ None,
365            Some(Vec::new()),
366            Some(vec!["ignored".into()]),
367        );
368
369        assert_eq!(
370            resolved,
371            ResolvedMcpOAuthScopes {
372                scopes: Vec::new(),
373                source: McpOAuthScopesSource::Configured,
374            }
375        );
376    }
377
378    #[test]
379    fn resolve_oauth_scopes_falls_back_to_empty() {
380        let resolved = resolve_oauth_scopes(
381            /*explicit_scopes*/ None, /*configured_scopes*/ None,
382            /*discovered_scopes*/ None,
383        );
384
385        assert_eq!(
386            resolved,
387            ResolvedMcpOAuthScopes {
388                scopes: Vec::new(),
389                source: McpOAuthScopesSource::Empty,
390            }
391        );
392    }
393
394    #[test]
395    fn should_retry_without_scopes_only_for_discovered_provider_errors() {
396        let discovered = ResolvedMcpOAuthScopes {
397            scopes: vec!["scope".to_string()],
398            source: McpOAuthScopesSource::Discovered,
399        };
400        let provider_error = anyhow!(OAuthProviderError::new(
401            Some("invalid_scope".to_string()),
402            Some("scope rejected".to_string()),
403        ));
404
405        assert!(should_retry_without_scopes(&discovered, &provider_error));
406
407        let configured = ResolvedMcpOAuthScopes {
408            scopes: vec!["scope".to_string()],
409            source: McpOAuthScopesSource::Configured,
410        };
411        assert!(!should_retry_without_scopes(&configured, &provider_error));
412        assert!(!should_retry_without_scopes(
413            &discovered,
414            &anyhow!("timed out waiting for OAuth callback"),
415        ));
416    }
417}