Skip to main content

codex_rmcp_client/
auth_status.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::Duration;
4
5use anyhow::Result;
6use codex_exec_server::HttpClient;
7use codex_protocol::protocol::McpAuthStatus;
8use futures::FutureExt;
9use reqwest::Client;
10use reqwest::header::AUTHORIZATION;
11use reqwest::header::HeaderMap;
12use rmcp::transport::AuthorizationManager;
13use rmcp::transport::auth::AuthError;
14use tracing::debug;
15
16use crate::oauth::StoredOAuthTokenStatus;
17use crate::oauth::oauth_token_status;
18use crate::oauth_http_client::OAuthHttpClientAdapter;
19use crate::utils::apply_default_headers;
20use crate::utils::build_default_headers;
21use codex_config::types::AuthKeyringBackendKind;
22use codex_config::types::OAuthCredentialsStoreMode;
23
24const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5);
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct StreamableHttpOAuthDiscovery {
28    pub scopes_supported: Option<Vec<String>>,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum McpLoginRequirement {
33    Login,
34    Reauthentication,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum McpAuthState {
39    Unsupported,
40    LoggedOut(McpLoginRequirement),
41    BearerToken,
42    OAuth,
43}
44
45impl From<McpAuthState> for McpAuthStatus {
46    fn from(value: McpAuthState) -> Self {
47        match value {
48            McpAuthState::Unsupported => Self::Unsupported,
49            McpAuthState::LoggedOut(_) => Self::NotLoggedIn,
50            McpAuthState::BearerToken => Self::BearerToken,
51            McpAuthState::OAuth => Self::OAuth,
52        }
53    }
54}
55
56enum AuthStatusCheck {
57    Complete(McpAuthState),
58    Discover(HeaderMap),
59}
60
61/// Determine the authentication status for a streamable HTTP MCP server.
62pub async fn determine_streamable_http_auth_status(
63    server_name: &str,
64    url: &str,
65    bearer_token_env_var: Option<&str>,
66    http_headers: Option<HashMap<String, String>>,
67    env_http_headers: Option<HashMap<String, String>>,
68    store_mode: OAuthCredentialsStoreMode,
69    keyring_backend_kind: AuthKeyringBackendKind,
70) -> Result<McpAuthState> {
71    let default_headers = match auth_status_before_discovery(
72        server_name,
73        url,
74        bearer_token_env_var,
75        http_headers,
76        env_http_headers,
77        store_mode,
78        keyring_backend_kind,
79    )? {
80        AuthStatusCheck::Complete(status) => return Ok(status),
81        AuthStatusCheck::Discover(default_headers) => default_headers,
82    };
83
84    determine_auth_status_from_discovery(
85        server_name,
86        url,
87        discover_streamable_http_oauth_with_headers(url, &default_headers).await,
88    )
89}
90
91/// Determine authentication status while routing OAuth discovery through the
92/// provided HTTP client.
93#[allow(clippy::too_many_arguments)]
94pub async fn determine_streamable_http_auth_status_with_http_client(
95    server_name: &str,
96    url: &str,
97    bearer_token_env_var: Option<&str>,
98    http_headers: Option<HashMap<String, String>>,
99    env_http_headers: Option<HashMap<String, String>>,
100    store_mode: OAuthCredentialsStoreMode,
101    keyring_backend_kind: AuthKeyringBackendKind,
102    http_client: Arc<dyn HttpClient>,
103) -> Result<McpAuthState> {
104    let default_headers = match auth_status_before_discovery(
105        server_name,
106        url,
107        bearer_token_env_var,
108        http_headers,
109        env_http_headers,
110        store_mode,
111        keyring_backend_kind,
112    )? {
113        AuthStatusCheck::Complete(status) => return Ok(status),
114        AuthStatusCheck::Discover(default_headers) => default_headers,
115    };
116    determine_auth_status_from_discovery(
117        server_name,
118        url,
119        discover_streamable_http_oauth_with_headers_and_http_client(
120            url,
121            default_headers,
122            http_client,
123        )
124        .await,
125    )
126}
127
128/// Determine authentication status using only configured and stored credentials.
129///
130/// Returns `None` when determining the status would require OAuth metadata discovery.
131pub fn determine_streamable_http_auth_status_from_credentials(
132    server_name: &str,
133    url: &str,
134    bearer_token_env_var: Option<&str>,
135    http_headers: Option<HashMap<String, String>>,
136    env_http_headers: Option<HashMap<String, String>>,
137    store_mode: OAuthCredentialsStoreMode,
138    keyring_backend_kind: AuthKeyringBackendKind,
139) -> Result<Option<McpAuthState>> {
140    match auth_status_before_discovery(
141        server_name,
142        url,
143        bearer_token_env_var,
144        http_headers,
145        env_http_headers,
146        store_mode,
147        keyring_backend_kind,
148    )? {
149        AuthStatusCheck::Complete(status) => Ok(Some(status)),
150        AuthStatusCheck::Discover(_) => Ok(None),
151    }
152}
153
154fn auth_status_before_discovery(
155    server_name: &str,
156    url: &str,
157    bearer_token_env_var: Option<&str>,
158    http_headers: Option<HashMap<String, String>>,
159    env_http_headers: Option<HashMap<String, String>>,
160    store_mode: OAuthCredentialsStoreMode,
161    keyring_backend_kind: AuthKeyringBackendKind,
162) -> Result<AuthStatusCheck> {
163    if bearer_token_env_var.is_some() {
164        return Ok(AuthStatusCheck::Complete(McpAuthState::BearerToken));
165    }
166
167    let default_headers = build_default_headers(http_headers, env_http_headers)?;
168    if default_headers.contains_key(AUTHORIZATION) {
169        return Ok(AuthStatusCheck::Complete(McpAuthState::BearerToken));
170    }
171
172    match oauth_token_status(server_name, url, store_mode, keyring_backend_kind)? {
173        StoredOAuthTokenStatus::Usable => {
174            return Ok(AuthStatusCheck::Complete(McpAuthState::OAuth));
175        }
176        StoredOAuthTokenStatus::AuthorizationRequired => {
177            return Ok(AuthStatusCheck::Complete(McpAuthState::LoggedOut(
178                McpLoginRequirement::Reauthentication,
179            )));
180        }
181        StoredOAuthTokenStatus::Missing => {}
182    }
183
184    Ok(AuthStatusCheck::Discover(default_headers))
185}
186
187fn determine_auth_status_from_discovery(
188    server_name: &str,
189    url: &str,
190    discovery: Result<Option<StreamableHttpOAuthDiscovery>>,
191) -> Result<McpAuthState> {
192    match discovery {
193        Ok(Some(_)) => Ok(McpAuthState::LoggedOut(McpLoginRequirement::Login)),
194        Ok(None) => Ok(McpAuthState::Unsupported),
195        Err(error) => {
196            debug!(
197                "failed to detect OAuth support for MCP server `{server_name}` at {url}: {error:?}"
198            );
199            Ok(McpAuthState::Unsupported)
200        }
201    }
202}
203
204/// Attempt to determine whether a streamable HTTP MCP server advertises OAuth login.
205pub async fn supports_oauth_login(url: &str) -> Result<bool> {
206    Ok(discover_streamable_http_oauth(
207        url, /*http_headers*/ None, /*env_http_headers*/ None,
208    )
209    .await?
210    .is_some())
211}
212
213pub async fn discover_streamable_http_oauth(
214    url: &str,
215    http_headers: Option<HashMap<String, String>>,
216    env_http_headers: Option<HashMap<String, String>>,
217) -> Result<Option<StreamableHttpOAuthDiscovery>> {
218    let default_headers = build_default_headers(http_headers, env_http_headers)?;
219    discover_streamable_http_oauth_with_headers(url, &default_headers).await
220}
221
222pub async fn discover_streamable_http_oauth_with_http_client(
223    url: &str,
224    http_headers: Option<HashMap<String, String>>,
225    env_http_headers: Option<HashMap<String, String>>,
226    http_client: Arc<dyn HttpClient>,
227) -> Result<Option<StreamableHttpOAuthDiscovery>> {
228    let default_headers = build_default_headers(http_headers, env_http_headers)?;
229    discover_streamable_http_oauth_with_headers_and_http_client(url, default_headers, http_client)
230        .await
231}
232
233async fn discover_streamable_http_oauth_with_headers(
234    url: &str,
235    default_headers: &HeaderMap,
236) -> Result<Option<StreamableHttpOAuthDiscovery>> {
237    // Use no_proxy to avoid a bug in the system-configuration crate that
238    // can result in a panic. See #8912.
239    let builder = Client::builder().timeout(DISCOVERY_TIMEOUT).no_proxy();
240    let client = apply_default_headers(builder, default_headers).build()?;
241    let mut authorization_manager = AuthorizationManager::new(url).await?;
242    authorization_manager.with_client(client)?;
243    discover_streamable_http_oauth_with_manager(&authorization_manager).await
244}
245
246async fn discover_streamable_http_oauth_with_headers_and_http_client(
247    url: &str,
248    default_headers: HeaderMap,
249    http_client: Arc<dyn HttpClient>,
250) -> Result<Option<StreamableHttpOAuthDiscovery>> {
251    let authorization_manager = AuthorizationManager::new_with_oauth_http_client(
252        url,
253        Arc::new(OAuthHttpClientAdapter::new(http_client, default_headers)),
254    )
255    .await?;
256    discover_streamable_http_oauth_with_manager(&authorization_manager).await
257}
258
259async fn discover_streamable_http_oauth_with_manager(
260    authorization_manager: &AuthorizationManager,
261) -> Result<Option<StreamableHttpOAuthDiscovery>> {
262    match authorization_manager.discover_metadata().boxed().await {
263        Ok(metadata) => Ok(Some(StreamableHttpOAuthDiscovery {
264            scopes_supported: normalize_scopes(metadata.scopes_supported),
265        })),
266        Err(AuthError::NoAuthorizationSupport) => Ok(None),
267        Err(err) => Err(err.into()),
268    }
269}
270
271fn normalize_scopes(scopes_supported: Option<Vec<String>>) -> Option<Vec<String>> {
272    let scopes_supported = scopes_supported?;
273
274    let mut normalized = Vec::new();
275    for scope in scopes_supported {
276        let scope = scope.trim();
277        if scope.is_empty() {
278            continue;
279        }
280        let scope = scope.to_string();
281        if !normalized.contains(&scope) {
282            normalized.push(scope);
283        }
284    }
285
286    if normalized.is_empty() {
287        None
288    } else {
289        Some(normalized)
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use axum::Json;
297    use axum::Router;
298    use axum::http::StatusCode;
299    use axum::http::header::WWW_AUTHENTICATE;
300    use axum::routing::get;
301    use pretty_assertions::assert_eq;
302    use serial_test::serial;
303    use std::collections::HashMap;
304    use std::ffi::OsString;
305    use tokio::task::JoinHandle;
306
307    struct TestServer {
308        url: String,
309        handle: JoinHandle<()>,
310    }
311
312    impl Drop for TestServer {
313        fn drop(&mut self) {
314            self.handle.abort();
315        }
316    }
317
318    async fn spawn_oauth_discovery_server(metadata: serde_json::Value) -> TestServer {
319        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
320            .await
321            .expect("listener should bind");
322        let address = listener.local_addr().expect("listener should have address");
323        let app = Router::new().route(
324            "/.well-known/oauth-authorization-server/mcp",
325            get({
326                let metadata = metadata.clone();
327                move || {
328                    let metadata = metadata.clone();
329                    async move { Json(metadata) }
330                }
331            }),
332        );
333        let handle = tokio::spawn(async move {
334            axum::serve(listener, app).await.expect("server should run");
335        });
336
337        TestServer {
338            url: format!("http://{address}/mcp"),
339            handle,
340        }
341    }
342
343    struct EnvVarGuard {
344        key: String,
345        original: Option<OsString>,
346    }
347
348    impl EnvVarGuard {
349        fn set(key: &str, value: &str) -> Self {
350            let original = std::env::var_os(key);
351            unsafe {
352                std::env::set_var(key, value);
353            }
354            Self {
355                key: key.to_string(),
356                original,
357            }
358        }
359    }
360
361    impl Drop for EnvVarGuard {
362        fn drop(&mut self) {
363            if let Some(value) = &self.original {
364                unsafe {
365                    std::env::set_var(&self.key, value);
366                }
367            } else {
368                unsafe {
369                    std::env::remove_var(&self.key);
370                }
371            }
372        }
373    }
374
375    #[tokio::test]
376    async fn determine_auth_status_uses_bearer_token_when_authorization_header_present() {
377        let status = determine_streamable_http_auth_status(
378            "server",
379            "not-a-url",
380            /*bearer_token_env_var*/ None,
381            Some(HashMap::from([(
382                "Authorization".to_string(),
383                "Bearer token".to_string(),
384            )])),
385            /*env_http_headers*/ None,
386            OAuthCredentialsStoreMode::Keyring,
387            AuthKeyringBackendKind::default(),
388        )
389        .await
390        .expect("status should compute");
391
392        assert_eq!(status, McpAuthState::BearerToken);
393    }
394
395    #[tokio::test]
396    #[serial(auth_status_env)]
397    async fn determine_auth_status_uses_bearer_token_when_env_authorization_header_present() {
398        let _guard = EnvVarGuard::set("CODEX_RMCP_CLIENT_AUTH_STATUS_TEST_TOKEN", "Bearer token");
399        let status = determine_streamable_http_auth_status(
400            "server",
401            "not-a-url",
402            /*bearer_token_env_var*/ None,
403            /*http_headers*/ None,
404            Some(HashMap::from([(
405                "Authorization".to_string(),
406                "CODEX_RMCP_CLIENT_AUTH_STATUS_TEST_TOKEN".to_string(),
407            )])),
408            OAuthCredentialsStoreMode::Keyring,
409            AuthKeyringBackendKind::default(),
410        )
411        .await
412        .expect("status should compute");
413
414        assert_eq!(status, McpAuthState::BearerToken);
415    }
416
417    #[tokio::test]
418    async fn discover_streamable_http_oauth_returns_normalized_scopes() {
419        let server = spawn_oauth_discovery_server(serde_json::json!({
420            "authorization_endpoint": "https://example.com/authorize",
421            "token_endpoint": "https://example.com/token",
422            "scopes_supported": ["profile", " email ", "profile", "", "   "],
423        }))
424        .await;
425
426        let discovery = discover_streamable_http_oauth(
427            &server.url,
428            /*http_headers*/ None,
429            /*env_http_headers*/ None,
430        )
431        .await
432        .expect("discovery should succeed")
433        .expect("oauth support should be detected");
434
435        assert_eq!(
436            discovery.scopes_supported,
437            Some(vec!["profile".to_string(), "email".to_string()])
438        );
439    }
440
441    #[tokio::test]
442    async fn discover_streamable_http_oauth_follows_protected_resource_metadata() {
443        let authorization_server = spawn_oauth_discovery_server(serde_json::json!({
444            "authorization_endpoint": "https://example.com/authorize",
445            "token_endpoint": "https://example.com/token",
446            "scopes_supported": ["read", " write ", "read"],
447        }))
448        .await;
449
450        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
451            .await
452            .expect("listener should bind");
453        let address = listener.local_addr().expect("listener should have address");
454        let resource_metadata_url = format!("http://{address}/oauth-resource");
455        let challenge = format!("Bearer resource_metadata=\"{resource_metadata_url}\"");
456        let authorization_server_url = authorization_server.url.clone();
457        let app = Router::new()
458            .route(
459                "/mcp",
460                get(move || {
461                    let challenge = challenge.clone();
462                    async move { (StatusCode::UNAUTHORIZED, [(WWW_AUTHENTICATE, challenge)]) }
463                }),
464            )
465            .route(
466                "/oauth-resource",
467                get(move || {
468                    let authorization_server_url = authorization_server_url.clone();
469                    async move {
470                        Json(serde_json::json!({
471                            "resource": format!("http://{address}/mcp"),
472                            "authorization_servers": [authorization_server_url],
473                        }))
474                    }
475                }),
476            );
477        let handle = tokio::spawn(async move {
478            axum::serve(listener, app).await.expect("server should run");
479        });
480        let resource_server = TestServer {
481            url: format!("http://{address}/mcp"),
482            handle,
483        };
484
485        let discovery = discover_streamable_http_oauth(
486            &resource_server.url,
487            /*http_headers*/ None,
488            /*env_http_headers*/ None,
489        )
490        .await
491        .expect("discovery should succeed")
492        .expect("oauth support should be detected");
493
494        assert_eq!(
495            discovery.scopes_supported,
496            Some(vec!["read".to_string(), "write".to_string()])
497        );
498    }
499
500    #[tokio::test]
501    async fn discover_streamable_http_oauth_ignores_empty_scopes() {
502        let server = spawn_oauth_discovery_server(serde_json::json!({
503            "authorization_endpoint": "https://example.com/authorize",
504            "token_endpoint": "https://example.com/token",
505            "scopes_supported": ["", "   "],
506        }))
507        .await;
508
509        let discovery = discover_streamable_http_oauth(
510            &server.url,
511            /*http_headers*/ None,
512            /*env_http_headers*/ None,
513        )
514        .await
515        .expect("discovery should succeed")
516        .expect("oauth support should be detected");
517
518        assert_eq!(discovery.scopes_supported, None);
519    }
520
521    #[tokio::test]
522    async fn supports_oauth_login_does_not_require_scopes_supported() {
523        let server = spawn_oauth_discovery_server(serde_json::json!({
524            "authorization_endpoint": "https://example.com/authorize",
525            "token_endpoint": "https://example.com/token",
526        }))
527        .await;
528
529        let supported = supports_oauth_login(&server.url)
530            .await
531            .expect("support check should succeed");
532
533        assert!(supported);
534    }
535}