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
61pub 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#[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
128pub 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
204pub async fn supports_oauth_login(url: &str) -> Result<bool> {
206 Ok(discover_streamable_http_oauth(
207 url, None, 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 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 None,
381 Some(HashMap::from([(
382 "Authorization".to_string(),
383 "Bearer token".to_string(),
384 )])),
385 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 None,
403 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 None,
429 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 None,
488 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 None,
512 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}