返回 CodeWhale
oauth.rs
根目录 / crates / tui / src / mcp / oauth.rs
1 use std::collections::HashMap;
2 use std::sync::Arc;
3 use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5 use anyhow::{Context, Result, anyhow, bail};
6 use base64::Engine as _;
7 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
8 use oauth2::TokenResponse;
9 use reqwest::Url;
10 use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
11 use rmcp::transport::AuthorizationManager;
12 use rmcp::transport::AuthorizationSession;
13 use rmcp::transport::auth::{AuthError, OAuthClientConfig, OAuthState, OAuthTokenResponse};
14 use serde::{Deserialize, Serialize};
15 use sha2::{Digest, Sha256};
16 use tiny_http::{Response, Server};
17 use tokio::sync::{Mutex, oneshot};
18 use tokio::time::timeout;
19 use tokio_util::sync::CancellationToken;
20 use urlencoding::decode;
21
22 use super::McpServerConfig;
23
24 const REFRESH_SKEW_MILLIS: u64 = 30_000;
25
26 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27 #[serde(rename_all = "snake_case")]
28 pub enum McpAuthStatus {
29 Unsupported,
30 NotLoggedIn,
31 BearerToken,
32 OAuth,
33 }
34
35 impl std::fmt::Display for McpAuthStatus {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 let text = match self {
38 Self::Unsupported => "Unsupported",
39 Self::NotLoggedIn => "Not logged in",
40 Self::BearerToken => "Bearer token",
41 Self::OAuth => "OAuth",
42 };
43 f.write_str(text)
44 }
45 }
46
47 pub fn error_looks_auth_required(error: &anyhow::Error) -> bool {
48 let text = format!("{error:#}").to_ascii_lowercase();
49 text.contains("401")
50 || text.contains("unauthorized")
51 || text.contains("authentication_required")
52 || text.contains("not logged in")
53 || text.contains("not-logged-in")
54 }
55
56 pub fn auth_required_login_hint(server_name: &str) -> String {
57 format!(
58 "MCP server '{server_name}' requires OAuth authentication. Run `codewhale mcp login {server_name}` to authenticate."
59 )
60 }
61
62 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
63 pub struct StoredMcpOAuthTokens {
64 pub server_name: String,
65 pub url: String,
66 pub client_id: String,
67 pub token_response: WrappedOAuthTokenResponse,
68 #[serde(default)]
69 pub expires_at: Option<u64>,
70 }
71
72 #[derive(Debug, Clone, Serialize, Deserialize)]
73 pub struct WrappedOAuthTokenResponse(pub OAuthTokenResponse);
74
75 impl PartialEq for WrappedOAuthTokenResponse {
76 fn eq(&self, other: &Self) -> bool {
77 match (serde_json::to_string(self), serde_json::to_string(other)) {
78 (Ok(left), Ok(right)) => left == right,
79 _ => false,
80 }
81 }
82 }
83
84 #[derive(Clone)]
85 pub struct McpOAuthRuntime {
86 inner: Arc<McpOAuthRuntimeInner>,
87 }
88
89 struct McpOAuthRuntimeInner {
90 server_name: String,
91 url: String,
92 manager: Arc<Mutex<AuthorizationManager>>,
93 last_tokens: Mutex<Option<StoredMcpOAuthTokens>>,
94 }
95
96 #[derive(Debug, Clone, PartialEq, Eq)]
97 pub struct McpOAuthDiscovery {
98 pub scopes_supported: Option<Vec<String>>,
99 }
100
101 #[derive(Debug, Clone, PartialEq, Eq)]
102 pub struct ResolvedMcpOAuthScopes {
103 pub scopes: Vec<String>,
104 pub source: McpOAuthScopesSource,
105 }
106
107 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
108 pub enum McpOAuthScopesSource {
109 Explicit,
110 Configured,
111 Discovered,
112 Empty,
113 }
114
115 #[derive(Debug, Clone, PartialEq, Eq)]
116 pub struct OAuthProviderError {
117 error: Option<String>,
118 error_description: Option<String>,
119 }
120
121 impl OAuthProviderError {
122 fn new(error: Option<String>, error_description: Option<String>) -> Self {
123 Self {
124 error,
125 error_description,
126 }
127 }
128 }
129
130 impl std::fmt::Display for OAuthProviderError {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 match (self.error.as_deref(), self.error_description.as_deref()) {
133 (Some(error), Some(description)) => {
134 write!(f, "OAuth provider returned `{error}`: {description}")
135 }
136 (Some(error), None) => write!(f, "OAuth provider returned `{error}`"),
137 (None, Some(description)) => write!(f, "OAuth error: {description}"),
138 (None, None) => write!(f, "OAuth provider returned an error"),
139 }
140 }
141 }
142
143 impl std::error::Error for OAuthProviderError {}
144
145 impl McpOAuthRuntime {
146 pub async fn from_server_config(
147 server_name: &str,
148 server: &McpServerConfig,
149 default_headers: HeaderMap,
150 ) -> Result<Option<Self>> {
151 if server.reviewed_plugin.is_some() {
152 return Ok(None);
153 }
154 let Some(url) = server.url.as_deref() else {
155 return Ok(None);
156 };
157 if server_has_manual_authorization(server) {
158 return Ok(None);
159 }
160 let Some(tokens) = load_oauth_tokens(server_name, url)? else {
161 return Ok(None);
162 };
163 Self::from_stored_tokens(server_name, url, tokens, default_headers)
164 .await
165 .map(Some)
166 }
167
168 async fn from_stored_tokens(
169 server_name: &str,
170 url: &str,
171 mut tokens: StoredMcpOAuthTokens,
172 default_headers: HeaderMap,
173 ) -> Result<Self> {
174 refresh_expires_in_from_timestamp(&mut tokens);
175 let client = apply_default_headers(crate::tls::reqwest_client_builder(), &default_headers)
176 .build()
177 .context("building MCP OAuth metadata client")?;
178 let mut state = OAuthState::new(url.to_string(), Some(client)).await?;
179 state
180 .set_credentials(&tokens.client_id, tokens.token_response.0.clone())
181 .await
182 .context("installing stored MCP OAuth credentials")?;
183
184 let manager = match state {
185 OAuthState::Authorized(manager) | OAuthState::Unauthorized(manager) => manager,
186 _ => bail!("unexpected MCP OAuth state while preparing stored credentials"),
187 };
188
189 Ok(Self {
190 inner: Arc::new(McpOAuthRuntimeInner {
191 server_name: server_name.to_string(),
192 url: url.to_string(),
193 manager: Arc::new(Mutex::new(manager)),
194 last_tokens: Mutex::new(Some(tokens)),
195 }),
196 })
197 }
198
199 pub async fn authorization_header(&self) -> Result<Option<String>> {
200 self.refresh_if_needed().await?;
201 let credentials = {
202 let guard = self.inner.manager.lock().await;
203 let (_client_id, credentials) = guard
204 .get_credentials()
205 .await
206 .context("reading MCP OAuth credentials")?;
207 credentials
208 };
209 let Some(credentials) = credentials else {
210 return Ok(None);
211 };
212 let token = credentials.access_token().secret().trim();
213 if token.is_empty() {
214 Ok(None)
215 } else {
216 Ok(Some(format!("Bearer {token}")))
217 }
218 }
219
220 async fn refresh_if_needed(&self) -> Result<()> {
221 let expires_at = {
222 let guard = self.inner.last_tokens.lock().await;
223 guard.as_ref().and_then(|tokens| tokens.expires_at)
224 };
225 if !token_needs_refresh(expires_at) {
226 return Ok(());
227 }
228
229 let refresh_result = {
230 let guard = self.inner.manager.lock().await;
231 guard.refresh_token().await
232 };
233 if let Err(err) = refresh_result {
234 let err = anyhow!(err);
235 if error_looks_auth_required(&err) {
236 self.clear_stored_tokens().await?;
237 }
238 return Err(err).with_context(|| {
239 format!(
240 "refreshing MCP OAuth token for server {}",
241 self.inner.server_name
242 )
243 });
244 }
245 self.persist_if_needed().await
246 }
247
248 async fn clear_stored_tokens(&self) -> Result<()> {
249 let mut last = self.inner.last_tokens.lock().await;
250 if last.take().is_some() {
251 delete_oauth_tokens(&self.inner.server_name, &self.inner.url)?;
252 }
253 Ok(())
254 }
255
256 async fn persist_if_needed(&self) -> Result<()> {
257 let (client_id, credentials) = {
258 let guard = self.inner.manager.lock().await;
259 guard
260 .get_credentials()
261 .await
262 .context("reading refreshed MCP OAuth credentials")?
263 };
264 let Some(credentials) = credentials else {
265 let mut last = self.inner.last_tokens.lock().await;
266 if last.take().is_some() {
267 delete_oauth_tokens(&self.inner.server_name, &self.inner.url)?;
268 }
269 return Ok(());
270 };
271
272 let new_response = WrappedOAuthTokenResponse(credentials.clone());
273 let mut last = self.inner.last_tokens.lock().await;
274 let same_token = last
275 .as_ref()
276 .map(|previous| previous.token_response == new_response)
277 .unwrap_or(false);
278 let expires_at = if same_token {
279 last.as_ref().and_then(|previous| previous.expires_at)
280 } else {
281 compute_expires_at_millis(&credentials)
282 };
283 let stored = StoredMcpOAuthTokens {
284 server_name: self.inner.server_name.clone(),
285 url: self.inner.url.clone(),
286 client_id,
287 token_response: new_response,
288 expires_at,
289 };
290 if last.as_ref() != Some(&stored) {
291 save_oauth_tokens(&stored)?;
292 *last = Some(stored);
293 }
294 Ok(())
295 }
296 }
297
298 pub async fn auth_status_for_server(name: &str, server: &McpServerConfig) -> McpAuthStatus {
299 if server.reviewed_plugin.is_some() || !server.is_enabled() || server.url.is_none() {
300 return McpAuthStatus::Unsupported;
301 }
302 if server_has_manual_authorization(server) {
303 return McpAuthStatus::BearerToken;
304 }
305 let Some(url) = server.url.as_deref() else {
306 return McpAuthStatus::Unsupported;
307 };
308 match load_oauth_tokens(name, url) {
309 Ok(Some(tokens)) if oauth_tokens_are_usable(&tokens) => return McpAuthStatus::OAuth,
310 Ok(Some(_)) => return McpAuthStatus::NotLoggedIn,
311 Ok(None) => {}
312 Err(err) => {
313 tracing::warn!(target: "mcp", server = %name, error = %err, "failed to read MCP OAuth tokens");
314 }
315 }
316
317 let headers = match build_default_headers(&server.headers, &server.env_headers) {
318 Ok(headers) => headers,
319 Err(err) => {
320 tracing::warn!(target: "mcp", server = %name, error = %err, "failed to build MCP OAuth discovery headers");
321 return McpAuthStatus::Unsupported;
322 }
323 };
324 match discover_streamable_http_oauth_with_headers(url, headers).await {
325 Ok(Some(_)) => McpAuthStatus::NotLoggedIn,
326 Ok(None) => McpAuthStatus::Unsupported,
327 Err(err) => {
328 tracing::debug!(target: "mcp", server = %name, error = %err, "MCP OAuth discovery failed");
329 McpAuthStatus::Unsupported
330 }
331 }
332 }
333
334 pub async fn oauth_login_support(server: &McpServerConfig) -> Result<Option<McpOAuthDiscovery>> {
335 if server.reviewed_plugin.is_some() {
336 return Ok(None);
337 }
338 let Some(url) = server.url.as_deref() else {
339 return Ok(None);
340 };
341 if server_has_manual_authorization(server) {
342 return Ok(None);
343 }
344 discover_streamable_http_oauth(url, server.headers.clone(), server.env_headers.clone()).await
345 }
346
347 pub async fn discover_streamable_http_oauth(
348 url: &str,
349 http_headers: HashMap<String, String>,
350 env_headers: HashMap<String, String>,
351 ) -> Result<Option<McpOAuthDiscovery>> {
352 let headers = build_default_headers(&http_headers, &env_headers)?;
353 discover_streamable_http_oauth_with_headers(url, headers).await
354 }
355
356 async fn discover_streamable_http_oauth_with_headers(
357 url: &str,
358 default_headers: HeaderMap,
359 ) -> Result<Option<McpOAuthDiscovery>> {
360 let client = apply_default_headers(crate::tls::reqwest_client_builder(), &default_headers)
361 .timeout(Duration::from_secs(5))
362 .build()
363 .context("building MCP OAuth discovery client")?;
364 let mut manager = AuthorizationManager::new(url).await?;
365 manager.with_client(client)?;
366 match manager.discover_metadata().await {
367 Ok(metadata) => Ok(Some(McpOAuthDiscovery {
368 scopes_supported: normalize_scopes(metadata.scopes_supported),
369 })),
370 Err(AuthError::NoAuthorizationSupport) => Ok(None),
371 Err(err) => Err(err.into()),
372 }
373 }
374
375 pub fn resolve_oauth_scopes(
376 explicit_scopes: Option<Vec<String>>,
377 configured_scopes: Vec<String>,
378 discovered_scopes: Option<Vec<String>>,
379 ) -> ResolvedMcpOAuthScopes {
380 if let Some(scopes) = explicit_scopes {
381 return ResolvedMcpOAuthScopes {
382 scopes,
383 source: McpOAuthScopesSource::Explicit,
384 };
385 }
386 if !configured_scopes.is_empty() {
387 return ResolvedMcpOAuthScopes {
388 scopes: configured_scopes,
389 source: McpOAuthScopesSource::Configured,
390 };
391 }
392 if let Some(scopes) = discovered_scopes
393 && !scopes.is_empty()
394 {
395 return ResolvedMcpOAuthScopes {
396 scopes,
397 source: McpOAuthScopesSource::Discovered,
398 };
399 }
400 ResolvedMcpOAuthScopes {
401 scopes: Vec::new(),
402 source: McpOAuthScopesSource::Empty,
403 }
404 }
405
406 pub async fn perform_oauth_login_for_server(
407 name: &str,
408 server: &McpServerConfig,
409 explicit_scopes: Option<Vec<String>>,
410 callback_port: Option<u16>,
411 callback_url: Option<&str>,
412 ) -> Result<()> {
413 perform_oauth_login_for_server_with_cancel(
414 name,
415 server,
416 explicit_scopes,
417 callback_port,
418 callback_url,
419 CancellationToken::new(),
420 )
421 .await
422 }
423
424 /// Run an MCP OAuth login that can be stopped by the caller.
425 ///
426 /// Cancellation drops the in-flight OAuth future before this function returns,
427 /// which also closes its callback listener. A caller that replaces one login
428 /// with another should await the cancelled call before starting the replacement.
429 pub async fn perform_oauth_login_for_server_with_cancel(
430 name: &str,
431 server: &McpServerConfig,
432 explicit_scopes: Option<Vec<String>>,
433 callback_port: Option<u16>,
434 callback_url: Option<&str>,
435 cancellation_token: CancellationToken,
436 ) -> Result<()> {
437 if server.reviewed_plugin.is_some() {
438 bail!(
439 "OAuth is disabled for plugin-contributed MCP servers in v0.9.1; use a reviewed environment-backed header or bearer token"
440 );
441 }
442 run_cancellable_oauth(
443 &cancellation_token,
444 perform_oauth_login_for_server_inner(
445 name,
446 server,
447 explicit_scopes,
448 callback_port,
449 callback_url,
450 ),
451 )
452 .await
453 }
454
455 async fn run_cancellable_oauth<F, T>(cancellation_token: &CancellationToken, future: F) -> Result<T>
456 where
457 F: std::future::Future<Output = Result<T>>,
458 {
459 tokio::select! {
460 biased;
461 _ = cancellation_token.cancelled() => bail!("OAuth login was cancelled"),
462 result = future => result,
463 }
464 }
465
466 async fn perform_oauth_login_for_server_inner(
467 name: &str,
468 server: &McpServerConfig,
469 explicit_scopes: Option<Vec<String>>,
470 callback_port: Option<u16>,
471 callback_url: Option<&str>,
472 ) -> Result<()> {
473 let Some(url) = server.url.as_deref() else {
474 bail!("OAuth login is only supported for URL-based MCP servers");
475 };
476 if server_has_manual_authorization(server) {
477 bail!("MCP server '{name}' already has bearer/static Authorization configured");
478 }
479
480 let discovery = if explicit_scopes.is_none() && server.scopes.is_empty() {
481 oauth_login_support(server).await?
482 } else {
483 None
484 };
485 let resolved_scopes = resolve_oauth_scopes(
486 explicit_scopes,
487 server.scopes.clone(),
488 discovery.and_then(|discovery| discovery.scopes_supported),
489 );
490
491 match perform_oauth_login(
492 name,
493 url,
494 server.headers.clone(),
495 server.env_headers.clone(),
496 &resolved_scopes.scopes,
497 server.oauth_client_id(),
498 server.oauth_resource.as_deref(),
499 callback_port,
500 callback_url,
501 )
502 .await
503 {
504 Ok(()) => Ok(()),
505 Err(err)
506 if resolved_scopes.source == McpOAuthScopesSource::Discovered
507 && err.downcast_ref::<OAuthProviderError>().is_some() =>
508 {
509 println!("OAuth provider rejected discovered scopes. Retrying without scopes...");
510 perform_oauth_login(
511 name,
512 url,
513 server.headers.clone(),
514 server.env_headers.clone(),
515 &[],
516 server.oauth_client_id(),
517 server.oauth_resource.as_deref(),
518 callback_port,
519 callback_url,
520 )
521 .await
522 }
523 Err(err) => Err(err),
524 }
525 }
526
527 #[allow(clippy::too_many_arguments)]
528 async fn perform_oauth_login(
529 server_name: &str,
530 server_url: &str,
531 http_headers: HashMap<String, String>,
532 env_headers: HashMap<String, String>,
533 scopes: &[String],
534 oauth_client_id: Option<&str>,
535 oauth_resource: Option<&str>,
536 callback_port: Option<u16>,
537 callback_url: Option<&str>,
538 ) -> Result<()> {
539 OauthLoginFlow::new(
540 server_name,
541 server_url,
542 http_headers,
543 env_headers,
544 scopes,
545 oauth_client_id,
546 oauth_resource,
547 callback_port,
548 callback_url,
549 )
550 .await?
551 .finish()
552 .await
553 }
554
555 pub fn delete_oauth_tokens_for_server(name: &str, server: &McpServerConfig) -> Result<bool> {
556 if server.reviewed_plugin.is_some() {
557 bail!("OAuth storage is disabled for plugin-contributed MCP servers in v0.9.1");
558 }
559 let Some(url) = server.url.as_deref() else {
560 bail!("OAuth logout is only supported for URL-based MCP servers");
561 };
562 delete_oauth_tokens(name, url)
563 }
564
565 fn server_has_manual_authorization(server: &McpServerConfig) -> bool {
566 server.bearer_token_env_var.is_some()
567 || contains_authorization_header(&server.headers)
568 || contains_authorization_header(&server.env_headers)
569 }
570
571 pub fn build_default_headers(
572 http_headers: &HashMap<String, String>,
573 env_headers: &HashMap<String, String>,
574 ) -> Result<HeaderMap> {
575 let mut headers = HeaderMap::new();
576 for (name, value) in http_headers {
577 insert_header(&mut headers, name, value)?;
578 }
579 for (name, env_var) in env_headers {
580 if let Ok(value) = std::env::var(env_var)
581 && !value.trim().is_empty()
582 {
583 insert_header(&mut headers, name, &value)?;
584 }
585 }
586 Ok(headers)
587 }
588
589 fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<()> {
590 if !super::headers::is_safe_custom_header(name, value) {
591 bail!("unsafe MCP HTTP header '{name}'");
592 }
593 let name = HeaderName::from_bytes(name.as_bytes())
594 .with_context(|| format!("invalid MCP HTTP header name '{name}'"))?;
595 let value = HeaderValue::from_str(value).with_context(|| "invalid MCP HTTP header value")?;
596 headers.insert(name, value);
597 Ok(())
598 }
599
600 pub fn apply_default_headers(
601 builder: reqwest::ClientBuilder,
602 headers: &HeaderMap,
603 ) -> reqwest::ClientBuilder {
604 if headers.is_empty() {
605 builder
606 } else {
607 builder.default_headers(headers.clone())
608 }
609 }
610
611 fn contains_authorization_header(headers: &HashMap<String, String>) -> bool {
612 headers
613 .keys()
614 .any(|key| key.trim().eq_ignore_ascii_case("authorization"))
615 }
616
617 fn normalize_scopes(scopes_supported: Option<Vec<String>>) -> Option<Vec<String>> {
618 let scopes_supported = scopes_supported?;
619 let mut normalized = Vec::new();
620 for scope in scopes_supported {
621 let scope = scope.trim();
622 if scope.is_empty() {
623 continue;
624 }
625 let scope = scope.to_string();
626 if !normalized.contains(&scope) {
627 normalized.push(scope);
628 }
629 }
630 (!normalized.is_empty()).then_some(normalized)
631 }
632
633 fn load_oauth_tokens(server_name: &str, url: &str) -> Result<Option<StoredMcpOAuthTokens>> {
634 let secrets = codewhale_secrets::Secrets::auto_detect();
635 let key = store_key(server_name, url);
636 let Some(serialized) = secrets
637 .get(&key)
638 .with_context(|| format!("reading MCP OAuth token for '{server_name}'"))?
639 else {
640 return Ok(None);
641 };
642 let mut tokens = parse_stored_oauth_tokens(&serialized, server_name)?;
643 refresh_expires_in_from_timestamp(&mut tokens);
644 Ok(Some(tokens))
645 }
646
647 fn parse_stored_oauth_tokens(serialized: &str, server_name: &str) -> Result<StoredMcpOAuthTokens> {
648 serde_json::from_str(serialized).map_err(|_| {
649 anyhow!(
650 "stored MCP OAuth token for '{server_name}' is not valid credential JSON; contents were omitted"
651 )
652 })
653 }
654
655 fn save_oauth_tokens(tokens: &StoredMcpOAuthTokens) -> Result<()> {
656 let secrets = codewhale_secrets::Secrets::auto_detect();
657 let key = store_key(&tokens.server_name, &tokens.url);
658 let serialized = serde_json::to_string(tokens).context("serializing MCP OAuth token")?;
659 secrets
660 .set(&key, &serialized)
661 .with_context(|| format!("saving MCP OAuth token for '{}'", tokens.server_name))
662 }
663
664 fn delete_oauth_tokens(server_name: &str, url: &str) -> Result<bool> {
665 let secrets = codewhale_secrets::Secrets::auto_detect();
666 let key = store_key(server_name, url);
667 let existed = secrets
668 .get(&key)
669 .with_context(|| format!("reading MCP OAuth token for '{server_name}'"))?
670 .is_some();
671 secrets
672 .delete(&key)
673 .with_context(|| format!("deleting MCP OAuth token for '{server_name}'"))?;
674 Ok(existed)
675 }
676
677 fn store_key(server_name: &str, url: &str) -> String {
678 let mut payload = Vec::with_capacity(server_name.len() + url.len() + 1);
679 payload.extend_from_slice(server_name.as_bytes());
680 payload.push(0);
681 payload.extend_from_slice(url.as_bytes());
682 let digest = Sha256::digest(&payload);
683 format!("mcp_oauth_{}", URL_SAFE_NO_PAD.encode(digest))
684 }
685
686 fn oauth_tokens_are_usable(tokens: &StoredMcpOAuthTokens) -> bool {
687 if tokens.client_id.trim().is_empty() {
688 return false;
689 }
690 let response = &tokens.token_response.0;
691 if token_needs_refresh(tokens.expires_at) {
692 return response
693 .refresh_token()
694 .is_some_and(|token| !token.secret().trim().is_empty());
695 }
696 !response.access_token().secret().trim().is_empty()
697 }
698
699 fn refresh_expires_in_from_timestamp(tokens: &mut StoredMcpOAuthTokens) {
700 let Some(expires_at) = tokens.expires_at else {
701 return;
702 };
703 match expires_in_from_timestamp(expires_at) {
704 Some(seconds) => {
705 let duration = Duration::from_secs(seconds);
706 tokens.token_response.0.set_expires_in(Some(&duration));
707 }
708 None => {
709 tokens
710 .token_response
711 .0
712 .set_expires_in(Some(&Duration::ZERO));
713 }
714 }
715 }
716
717 fn compute_expires_at_millis(response: &OAuthTokenResponse) -> Option<u64> {
718 let expires = response.expires_in()?;
719 let now = SystemTime::now()
720 .duration_since(UNIX_EPOCH)
721 .ok()?
722 .as_millis() as u64;
723 Some(now.saturating_add(expires.as_millis() as u64))
724 }
725
726 fn expires_in_from_timestamp(expires_at: u64) -> Option<u64> {
727 let now = SystemTime::now()
728 .duration_since(UNIX_EPOCH)
729 .ok()?
730 .as_millis() as u64;
731 if expires_at <= now {
732 return None;
733 }
734 Some((expires_at - now) / 1000)
735 }
736
737 fn token_needs_refresh(expires_at: Option<u64>) -> bool {
738 let Some(expires_at) = expires_at else {
739 return false;
740 };
741 let now = SystemTime::now()
742 .duration_since(UNIX_EPOCH)
743 .map(|duration| duration.as_millis() as u64)
744 .unwrap_or(0);
745 now.saturating_add(REFRESH_SKEW_MILLIS) >= expires_at
746 }
747
748 struct CallbackServerGuard {
749 server: Arc<Server>,
750 }
751
752 impl Drop for CallbackServerGuard {
753 fn drop(&mut self) {
754 self.server.unblock();
755 }
756 }
757
758 struct OauthLoginFlow {
759 auth_url: String,
760 oauth_state: OAuthState,
761 rx: oneshot::Receiver<CallbackResult>,
762 guard: CallbackServerGuard,
763 server_name: String,
764 server_url: String,
765 }
766
767 impl OauthLoginFlow {
768 #[allow(clippy::too_many_arguments)]
769 async fn new(
770 server_name: &str,
771 server_url: &str,
772 http_headers: HashMap<String, String>,
773 env_headers: HashMap<String, String>,
774 scopes: &[String],
775 oauth_client_id: Option<&str>,
776 oauth_resource: Option<&str>,
777 callback_port: Option<u16>,
778 callback_url: Option<&str>,
779 ) -> Result<Self> {
780 let bind_host = callback_bind_host(callback_url);
781 let bind_addr = match callback_port {
782 Some(0) => bail!("invalid MCP OAuth callback port 0"),
783 Some(port) => format!("{bind_host}:{port}"),
784 None => format!("{bind_host}:0"),
785 };
786 let server = Arc::new(Server::http(&bind_addr).map_err(|err| anyhow!(err))?);
787 let guard = CallbackServerGuard {
788 server: Arc::clone(&server),
789 };
790 let redirect_uri = resolve_redirect_uri(&server, callback_url)?;
791 let callback_id = callback_id_from_server_url(server_url)?;
792 let redirect_uri = append_callback_id_to_redirect_uri(&redirect_uri, &callback_id)?;
793 let callback_path = callback_path_from_redirect_uri(&redirect_uri)?;
794
795 let (tx, rx) = oneshot::channel();
796 spawn_callback_server(server, tx, callback_path);
797
798 let headers = build_default_headers(&http_headers, &env_headers)?;
799 let client = apply_default_headers(crate::tls::reqwest_client_builder(), &headers)
800 .build()
801 .context("building MCP OAuth login client")?;
802 let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect();
803 let oauth_state = start_authorization(
804 server_url,
805 client,
806 &scope_refs,
807 &redirect_uri,
808 oauth_client_id,
809 )
810 .await?;
811 let auth_url = append_query_param(
812 &oauth_state.get_authorization_url().await?,
813 "resource",
814 oauth_resource,
815 );
816
817 Ok(Self {
818 auth_url,
819 oauth_state,
820 rx,
821 guard,
822 server_name: server_name.to_string(),
823 server_url: server_url.to_string(),
824 })
825 }
826
827 async fn finish(mut self) -> Result<()> {
828 println!(
829 "Authorize `{}` by opening this URL in your browser:\n{}\n",
830 self.server_name, self.auth_url
831 );
832 if webbrowser::open(&self.auth_url).is_err() {
833 eprintln!("Browser launch failed; copy the URL above manually.");
834 }
835 println!(
836 "Waiting for browser authorization for MCP server '{}'...",
837 self.server_name
838 );
839
840 let result = async {
841 let callback = timeout(Duration::from_secs(300), &mut self.rx)
842 .await
843 .with_context(|| {
844 format!(
845 "timed out waiting for OAuth callback for MCP server '{}'. Retry from a terminal, or use task_shell_start/background shell if an agent is running the login flow.",
846 self.server_name
847 )
848 })?
849 .context("OAuth callback was cancelled")?;
850 let OauthCallbackResult { code, state } = match callback {
851 CallbackResult::Success(callback) => callback,
852 CallbackResult::Error(error) => return Err(anyhow!(error)),
853 };
854
855 self.oauth_state
856 .handle_callback(&code, &state)
857 .await
858 .context("handling MCP OAuth callback")?;
859
860 let (client_id, credentials) = self
861 .oauth_state
862 .get_credentials()
863 .await
864 .context("reading MCP OAuth credentials")?;
865 let credentials =
866 credentials.ok_or_else(|| anyhow!("OAuth provider did not return credentials"))?;
867 let stored = StoredMcpOAuthTokens {
868 server_name: self.server_name.clone(),
869 url: self.server_url.clone(),
870 client_id,
871 expires_at: compute_expires_at_millis(&credentials),
872 token_response: WrappedOAuthTokenResponse(credentials),
873 };
874 save_oauth_tokens(&stored)
875 }
876 .await;
877
878 drop(self.guard);
879 result
880 }
881 }
882
883 async fn start_authorization(
884 server_url: &str,
885 client: reqwest::Client,
886 scopes: &[&str],
887 redirect_uri: &str,
888 oauth_client_id: Option<&str>,
889 ) -> Result<OAuthState> {
890 let Some(client_id) = oauth_client_id.filter(|client_id| !client_id.trim().is_empty()) else {
891 let mut oauth_state = OAuthState::new(server_url, Some(client)).await?;
892 oauth_state
893 .start_authorization(scopes, redirect_uri, Some("CodeWhale"))
894 .await?;
895 return Ok(oauth_state);
896 };
897
898 let mut manager = AuthorizationManager::new(server_url).await?;
899 manager.with_client(client)?;
900 let metadata = manager.discover_metadata().await?;
901 manager.set_metadata(metadata);
902 manager.configure_client(
903 OAuthClientConfig::new(client_id, redirect_uri)
904 .with_scopes(scopes.iter().map(|scope| (*scope).to_string()).collect()),
905 )?;
906 let auth_url = manager.get_authorization_url(scopes).await?;
907 Ok(OAuthState::Session(
908 AuthorizationSession::for_scope_upgrade(manager, auth_url, redirect_uri),
909 ))
910 }
911
912 fn spawn_callback_server(
913 server: Arc<Server>,
914 tx: oneshot::Sender<CallbackResult>,
915 expected_callback_path: String,
916 ) {
917 tokio::task::spawn_blocking(move || {
918 while let Ok(request) = server.recv() {
919 let path = request.url().to_string();
920 match parse_oauth_callback(&path, &expected_callback_path) {
921 CallbackOutcome::Success(callback) => {
922 let response = Response::from_string(
923 "Authentication complete. You may close this window.",
924 );
925 let _ = request.respond(response);
926 let _ = tx.send(CallbackResult::Success(callback));
927 break;
928 }
929 CallbackOutcome::Error(error) => {
930 let response = Response::from_string(error.to_string()).with_status_code(400);
931 let _ = request.respond(response);
932 let _ = tx.send(CallbackResult::Error(error));
933 break;
934 }
935 CallbackOutcome::Invalid => {
936 let response =
937 Response::from_string("Invalid OAuth callback").with_status_code(400);
938 let _ = request.respond(response);
939 }
940 }
941 }
942 });
943 }
944
945 #[derive(Debug, Clone, PartialEq, Eq)]
946 struct OauthCallbackResult {
947 code: String,
948 state: String,
949 }
950
951 enum CallbackResult {
952 Success(OauthCallbackResult),
953 Error(OAuthProviderError),
954 }
955
956 #[derive(Debug, Clone, PartialEq, Eq)]
957 enum CallbackOutcome {
958 Success(OauthCallbackResult),
959 Error(OAuthProviderError),
960 Invalid,
961 }
962
963 fn parse_oauth_callback(path: &str, expected_callback_path: &str) -> CallbackOutcome {
964 let Some((route, query)) = path.split_once('?') else {
965 return CallbackOutcome::Invalid;
966 };
967 if route != expected_callback_path {
968 return CallbackOutcome::Invalid;
969 }
970
971 let mut code = None;
972 let mut state = None;
973 let mut error = None;
974 let mut error_description = None;
975 for pair in query.split('&') {
976 let Some((key, value)) = pair.split_once('=') else {
977 continue;
978 };
979 let Ok(decoded) = decode(value) else {
980 continue;
981 };
982 let decoded = decoded.into_owned();
983 match key {
984 "code" => code = Some(decoded),
985 "state" => state = Some(decoded),
986 "error" => error = Some(decoded),
987 "error_description" => error_description = Some(decoded),
988 _ => {}
989 }
990 }
991
992 if let (Some(code), Some(state)) = (code, state) {
993 return CallbackOutcome::Success(OauthCallbackResult { code, state });
994 }
995 if error.is_some() || error_description.is_some() {
996 return CallbackOutcome::Error(OAuthProviderError::new(error, error_description));
997 }
998 CallbackOutcome::Invalid
999 }
1000
1001 fn local_redirect_uri(server: &Server) -> Result<String> {
1002 match server.server_addr() {
1003 tiny_http::ListenAddr::IP(std::net::SocketAddr::V4(addr)) => {
1004 Ok(format!("http://{}:{}/callback", addr.ip(), addr.port()))
1005 }
1006 tiny_http::ListenAddr::IP(std::net::SocketAddr::V6(addr)) => {
1007 Ok(format!("http://[{}]:{}/callback", addr.ip(), addr.port()))
1008 }
1009 #[cfg(not(target_os = "windows"))]
1010 _ => Err(anyhow!("unable to determine callback address")),
1011 }
1012 }
1013
1014 fn resolve_redirect_uri(server: &Server, callback_url: Option<&str>) -> Result<String> {
1015 let Some(callback_url) = callback_url else {
1016 return local_redirect_uri(server);
1017 };
1018 Url::parse(callback_url)
1019 .with_context(|| format!("invalid MCP OAuth callback URL '{callback_url}'"))?;
1020 Ok(callback_url.to_string())
1021 }
1022
1023 fn callback_bind_host(callback_url: Option<&str>) -> &'static str {
1024 let Some(callback_url) = callback_url else {
1025 return "127.0.0.1";
1026 };
1027 let Ok(parsed) = Url::parse(callback_url) else {
1028 return "127.0.0.1";
1029 };
1030 match parsed.host_str() {
1031 Some("localhost" | "127.0.0.1" | "::1") | None => "127.0.0.1",
1032 Some(_) => "0.0.0.0",
1033 }
1034 }
1035
1036 fn callback_id_from_server_url(server_url: &str) -> Result<String> {
1037 let mut parsed =
1038 Url::parse(server_url).with_context(|| format!("invalid MCP server URL '{server_url}'"))?;
1039 parsed
1040 .host_str()
1041 .ok_or_else(|| anyhow!("MCP server URL '{server_url}' must include a host"))?;
1042 parsed.set_fragment(None);
1043 let digest = Sha256::digest(parsed.as_str().as_bytes());
1044 Ok(URL_SAFE_NO_PAD.encode(&digest[..9]))
1045 }
1046
1047 fn append_callback_id_to_redirect_uri(redirect_uri: &str, callback_id: &str) -> Result<String> {
1048 let mut parsed = Url::parse(redirect_uri)
1049 .with_context(|| format!("invalid redirect URI '{redirect_uri}'"))?;
1050 let path = parsed.path();
1051 let new_path = if path.ends_with('/') {
1052 format!("{path}{callback_id}")
1053 } else {
1054 format!("{path}/{callback_id}")
1055 };
1056 parsed.set_path(&new_path);
1057 Ok(parsed.to_string())
1058 }
1059
1060 fn callback_path_from_redirect_uri(redirect_uri: &str) -> Result<String> {
1061 let parsed = Url::parse(redirect_uri)
1062 .with_context(|| format!("invalid redirect URI '{redirect_uri}'"))?;
1063 Ok(parsed.path().to_string())
1064 }
1065
1066 fn append_query_param(url: &str, key: &str, value: Option<&str>) -> String {
1067 let Some(value) = value else {
1068 return url.to_string();
1069 };
1070 let value = value.trim();
1071 if value.is_empty() {
1072 return url.to_string();
1073 }
1074 if let Ok(mut parsed) = Url::parse(url) {
1075 parsed.query_pairs_mut().append_pair(key, value);
1076 return parsed.to_string();
1077 }
1078 let separator = if url.contains('?') { "&" } else { "?" };
1079 format!("{url}{separator}{key}={}", urlencoding::encode(value))
1080 }
1081
1082 impl McpServerConfig {
1083 pub fn oauth_client_id(&self) -> Option<&str> {
1084 self.oauth
1085 .as_ref()
1086 .and_then(|oauth| oauth.client_id.as_deref())
1087 }
1088 }
1089
1090 #[cfg(test)]
1091 mod tests {
1092 use super::*;
1093 use std::sync::atomic::{AtomicBool, Ordering};
1094
1095 #[test]
1096 fn resolve_oauth_scopes_prefers_explicit() {
1097 let resolved = resolve_oauth_scopes(
1098 Some(vec!["explicit".to_string()]),
1099 vec!["configured".to_string()],
1100 Some(vec!["discovered".to_string()]),
1101 );
1102 assert_eq!(resolved.source, McpOAuthScopesSource::Explicit);
1103 assert_eq!(resolved.scopes, vec!["explicit"]);
1104 }
1105
1106 #[test]
1107 fn parse_oauth_callback_accepts_success() {
1108 let parsed = parse_oauth_callback("/callback/id?code=abc&state=xyz", "/callback/id");
1109 assert!(matches!(parsed, CallbackOutcome::Success(_)));
1110 }
1111
1112 #[test]
1113 fn parse_oauth_callback_accepts_provider_error() {
1114 let parsed = parse_oauth_callback(
1115 "/callback/id?error=invalid_scope&error_description=nope",
1116 "/callback/id",
1117 );
1118 assert!(matches!(parsed, CallbackOutcome::Error(_)));
1119 }
1120
1121 #[test]
1122 fn store_key_does_not_include_raw_url_or_name() {
1123 let key = store_key("github", "https://example.com/mcp");
1124 assert!(key.starts_with("mcp_oauth_"));
1125 assert!(!key.contains("github"));
1126 assert!(!key.contains("example.com"));
1127 }
1128
1129 #[test]
1130 fn malformed_stored_oauth_diagnostic_omits_secret_contents_and_keys() {
1131 let secret = "cw-secret-mcp-oauth-4507";
1132 let serialized =
1133 format!(r#"{{"token_response":{{"access_token":"{secret}"}} trailing-junk}}"#);
1134 let error = parse_stored_oauth_tokens(&serialized, "private")
1135 .expect_err("malformed credential JSON must fail");
1136 let diagnostic = format!("{error:#}");
1137 assert!(!diagnostic.contains(secret), "{diagnostic}");
1138 assert!(!diagnostic.contains("access_token"), "{diagnostic}");
1139 assert!(diagnostic.contains("contents were omitted"), "{diagnostic}");
1140 }
1141
1142 #[test]
1143 fn auth_required_classifier_matches_http_401_shapes() {
1144 let err = anyhow!("MCP Streamable HTTP rejected status=401 Unauthorized");
1145 assert!(error_looks_auth_required(&err));
1146
1147 let err = anyhow!("authentication_required for remote server");
1148 assert!(error_looks_auth_required(&err));
1149
1150 let err = anyhow!("connection refused");
1151 assert!(!error_looks_auth_required(&err));
1152 }
1153
1154 #[test]
1155 fn auth_required_login_hint_names_server() {
1156 let hint = auth_required_login_hint("nordic-mcp");
1157 assert!(hint.contains("nordic-mcp"));
1158 assert!(hint.contains("codewhale mcp login nordic-mcp"));
1159 }
1160
1161 #[tokio::test]
1162 async fn cancellable_oauth_drops_in_flight_flow_before_returning() {
1163 struct DropFlag(Arc<AtomicBool>);
1164 impl Drop for DropFlag {
1165 fn drop(&mut self) {
1166 self.0.store(true, Ordering::SeqCst);
1167 }
1168 }
1169
1170 let cancellation_token = CancellationToken::new();
1171 let cancel_from_task = cancellation_token.clone();
1172 let dropped = Arc::new(AtomicBool::new(false));
1173 let flow_dropped = Arc::clone(&dropped);
1174 let pending_flow = async move {
1175 let _guard = DropFlag(flow_dropped);
1176 std::future::pending::<Result<()>>().await
1177 };
1178 tokio::spawn(async move {
1179 tokio::task::yield_now().await;
1180 cancel_from_task.cancel();
1181 });
1182
1183 let error = run_cancellable_oauth(&cancellation_token, pending_flow)
1184 .await
1185 .expect_err("cancellation should stop the pending OAuth flow");
1186
1187 assert!(error.to_string().contains("OAuth login was cancelled"));
1188 assert!(
1189 dropped.load(Ordering::SeqCst),
1190 "the callback-server guard must be dropped before cancellation returns"
1191 );
1192 }
1193 }
1194
1194 lines RUST