1use anyhow::{Context, Result, anyhow, bail};
4use base64::Engine;
5use base64::engine::general_purpose::URL_SAFE_NO_PAD;
6use reqwest::Url;
7use ring::rand::{SecureRandom, SystemRandom};
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10
11use crate::credentials::{AuthCredentialsStoreMode, CredentialStorage};
12use crate::pkce::{PkceChallenge, generate_pkce_challenge};
13
14const DEFAULT_CALLBACK_PORT: u16 = 8768;
15const DEFAULT_FLOW_TIMEOUT_SECS: u64 = 300;
16const REFRESH_SKEW_SECS: u64 = 60;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
21#[serde(default)]
22pub struct McpOAuthConfig {
23 pub authorization_url: String,
25 pub token_url: String,
27 pub client_id: String,
29 #[serde(default)]
31 pub scopes: Vec<String>,
32 #[serde(default)]
34 audience: Option<String>,
35 pub callback_port: u16,
37 flow_timeout_secs: u64,
39 #[serde(default)]
41 pub credentials_store_mode: AuthCredentialsStoreMode,
42 #[serde(default)]
44 extra_auth_params: BTreeMap<String, String>,
45 #[serde(default)]
47 extra_token_params: BTreeMap<String, String>,
48}
49
50impl Default for McpOAuthConfig {
51 fn default() -> Self {
52 Self {
53 authorization_url: String::new(),
54 token_url: String::new(),
55 client_id: String::new(),
56 scopes: Vec::new(),
57 audience: None,
58 callback_port: DEFAULT_CALLBACK_PORT,
59 flow_timeout_secs: DEFAULT_FLOW_TIMEOUT_SECS,
60 credentials_store_mode: AuthCredentialsStoreMode::default(),
61 extra_auth_params: BTreeMap::new(),
62 extra_token_params: BTreeMap::new(),
63 }
64 }
65}
66
67fn validate_endpoint_url(what: &str, provider_name: &str, raw: &str) -> Result<Url> {
73 let parsed = Url::parse(raw).with_context(|| format!("MCP provider '{provider_name}' has an invalid {what}"))?;
74 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
75 bail!("MCP provider '{provider_name}' {what} must be an http(s) URL with a host");
76 }
77 Ok(parsed)
78}
79
80impl McpOAuthConfig {
81 fn validate(&self, provider_name: &str) -> Result<()> {
82 if self.authorization_url.trim().is_empty() {
83 bail!("MCP provider '{provider_name}' is missing oauth.authorization_url");
84 }
85 if self.token_url.trim().is_empty() {
86 bail!("MCP provider '{provider_name}' is missing oauth.token_url");
87 }
88 if self.client_id.trim().is_empty() {
89 bail!("MCP provider '{provider_name}' is missing oauth.client_id");
90 }
91 drop(validate_endpoint_url("oauth.authorization_url", provider_name, &self.authorization_url)?);
92 drop(validate_endpoint_url("oauth.token_url", provider_name, &self.token_url)?);
93 Ok(())
94 }
95
96 fn callback_url(&self) -> String {
97 format!("http://localhost:{}/auth/callback", self.callback_port)
98 }
99}
100
101#[derive(Clone, Serialize, Deserialize)]
103pub struct McpOAuthToken {
104 access_token: String,
105 refresh_token: Option<String>,
106 token_type: Option<String>,
107 scope: Option<String>,
108 obtained_at: u64,
109 expires_at: Option<u64>,
110 #[serde(default)]
111 binding: Option<McpOAuthBinding>,
112}
113
114impl std::fmt::Debug for McpOAuthToken {
115 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 formatter
117 .debug_struct("McpOAuthToken")
118 .field("access_token", &"[REDACTED]")
119 .field("refresh_token", &self.refresh_token.as_ref().map(|_| "[REDACTED]"))
120 .field("token_type", &self.token_type)
121 .field("scope", &self.scope)
122 .field("obtained_at", &self.obtained_at)
123 .field("expires_at", &self.expires_at)
124 .field("binding", &self.binding.as_ref().map(|_| "[REDACTED]"))
125 .finish()
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130struct McpOAuthBinding {
131 provider: String,
132 authorization_url: String,
133 token_url: String,
134 client_id: String,
135 audience: Option<String>,
136 scopes: Vec<String>,
137 extra_auth_params: BTreeMap<String, String>,
138 extra_token_params: BTreeMap<String, String>,
139 resource: String,
140}
141
142impl McpOAuthBinding {
143 fn new(provider: &str, config: &McpOAuthConfig, resource: &str) -> Result<Self> {
144 config.validate(provider)?;
145 let resource = validate_endpoint_url("protected resource URL", provider, resource)?;
146 Ok(Self {
147 provider: provider.to_owned(),
148 authorization_url: config.authorization_url.clone(),
149 token_url: config.token_url.clone(),
150 client_id: config.client_id.clone(),
151 audience: config.audience.clone(),
152 scopes: config.scopes.clone(),
153 extra_auth_params: config.extra_auth_params.clone(),
154 extra_token_params: config.extra_token_params.clone(),
155 resource: resource.to_string(),
156 })
157 }
158}
159
160impl McpOAuthToken {
161 fn is_refresh_due(&self) -> bool {
162 self.expires_at
163 .is_some_and(|expires_at| now_secs().saturating_add(REFRESH_SKEW_SECS) >= expires_at)
164 }
165}
166
167#[derive(Debug, Clone)]
169pub enum McpOAuthStatus {
170 Authenticated { age_seconds: u64, expires_in: Option<u64> },
171 NotAuthenticated,
172}
173
174#[derive(Debug, Clone)]
176pub struct McpOAuthPreparedLogin {
177 pub auth_url: String,
178 pub callback_port: u16,
179 pub timeout_secs: u64,
180 pkce: PkceChallenge,
181 state: String,
182 binding: Option<McpOAuthBinding>,
183}
184
185impl McpOAuthPreparedLogin {
186 #[must_use]
187 pub fn expected_state(&self) -> &str {
188 &self.state
189 }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct McpOAuthLoginCompletion {
195 pub name: String,
196 success: bool,
197 error: Option<String>,
198}
199
200#[derive(Debug, Clone, Default)]
202pub struct McpOAuthService;
203
204impl McpOAuthService {
205 #[must_use]
206 pub fn new() -> Self {
207 Self
208 }
209
210 pub fn prepare_login(&self, provider_name: &str, config: &McpOAuthConfig) -> Result<McpOAuthPreparedLogin> {
215 config.validate(provider_name)?;
216 let pkce = generate_pkce_challenge()?;
217 let state = generate_state()?;
218 let auth_url = build_auth_url(config, &pkce, &state)?;
219 Ok(McpOAuthPreparedLogin {
220 auth_url,
221 callback_port: config.callback_port,
222 timeout_secs: config.flow_timeout_secs,
223 pkce,
224 state,
225 binding: None,
226 })
227 }
228
229 pub fn prepare_login_for_resource(
231 &self,
232 provider_name: &str,
233 config: &McpOAuthConfig,
234 resource: &str,
235 ) -> Result<McpOAuthPreparedLogin> {
236 let mut prepared = self.prepare_login(provider_name, config)?;
237 prepared.binding = Some(McpOAuthBinding::new(provider_name, config, resource)?);
238 Ok(prepared)
239 }
240
241 pub async fn complete_login(
242 &self,
243 provider_name: &str,
244 config: &McpOAuthConfig,
245 prepared: &McpOAuthPreparedLogin,
246 code: &str,
247 ) -> Result<McpOAuthLoginCompletion> {
248 config.validate(provider_name)?;
249 let binding = prepared
250 .binding
251 .as_ref()
252 .context("MCP OAuth login requires a protected resource")?;
253 if *binding != McpOAuthBinding::new(provider_name, config, &binding.resource)? {
254 bail!("MCP OAuth configuration changed during login; start login again");
255 }
256 let mut token = exchange_code_for_token(config, code, &prepared.pkce).await?;
257 token.binding = Some(binding.clone());
258 save_token(provider_name, &token, config.credentials_store_mode)?;
259 Ok(McpOAuthLoginCompletion {
260 name: provider_name.to_string(),
261 success: true,
262 error: None,
263 })
264 }
265
266 pub fn status(&self, provider_name: &str, storage_mode: AuthCredentialsStoreMode) -> Result<McpOAuthStatus> {
267 let Some(token) = load_token(provider_name, storage_mode)? else {
268 return Ok(McpOAuthStatus::NotAuthenticated);
269 };
270 if token.binding.is_none() {
271 return Ok(McpOAuthStatus::NotAuthenticated);
272 }
273 let now = now_secs();
274 Ok(McpOAuthStatus::Authenticated {
275 age_seconds: now.saturating_sub(token.obtained_at),
276 expires_in: token.expires_at.map(|expires_at| expires_at.saturating_sub(now)),
277 })
278 }
279
280 pub fn status_for_resource(
282 &self,
283 provider_name: &str,
284 config: &McpOAuthConfig,
285 resource: &str,
286 ) -> Result<McpOAuthStatus> {
287 let binding = McpOAuthBinding::new(provider_name, config, resource)?;
288 let Some(token) = load_token(provider_name, config.credentials_store_mode)? else {
289 return Ok(McpOAuthStatus::NotAuthenticated);
290 };
291 if token.binding.as_ref() != Some(&binding) {
292 return Ok(McpOAuthStatus::NotAuthenticated);
293 }
294 let now = now_secs();
295 Ok(McpOAuthStatus::Authenticated {
296 age_seconds: now.saturating_sub(token.obtained_at),
297 expires_in: token.expires_at.map(|expires_at| expires_at.saturating_sub(now)),
298 })
299 }
300
301 pub fn load_token(
302 &self,
303 provider_name: &str,
304 storage_mode: AuthCredentialsStoreMode,
305 ) -> Result<Option<McpOAuthToken>> {
306 load_token(provider_name, storage_mode)
307 }
308
309 #[deprecated(
311 since = "0.180.6",
312 note = "use `resolve_access_token_for_resource` with the receiving resource"
313 )]
314 pub async fn resolve_access_token(&self, provider_name: &str, config: &McpOAuthConfig) -> Result<Option<String>> {
315 let _ = (provider_name, config);
316 bail!("MCP OAuth token resolution requires a protected resource")
317 }
318
319 pub async fn resolve_access_token_for_resource(
321 &self,
322 provider_name: &str,
323 config: &McpOAuthConfig,
324 resource: &str,
325 ) -> Result<Option<String>> {
326 let binding = McpOAuthBinding::new(provider_name, config, resource)?;
327 let Some(mut token) = load_token(provider_name, config.credentials_store_mode)? else {
328 return Ok(None);
329 };
330
331 if token.binding.as_ref() != Some(&binding) {
332 return Ok(None);
333 }
334
335 if token.is_refresh_due() {
336 if token.refresh_token.is_some() {
337 token = refresh_token(config, &token).await?;
338 token.binding = Some(binding);
339 save_token(provider_name, &token, config.credentials_store_mode)?;
340 } else {
341 bail!(
342 "Stored MCP OAuth token for '{provider_name}' expired and cannot be refreshed. Run `vtcode mcp login {provider_name}` again."
343 );
344 }
345 }
346
347 Ok(Some(token.access_token))
348 }
349
350 pub fn logout(
351 &self,
352 provider_name: &str,
353 storage_mode: AuthCredentialsStoreMode,
354 ) -> Result<McpOAuthLoginCompletion> {
355 clear_token(provider_name, storage_mode)?;
356 Ok(McpOAuthLoginCompletion {
357 name: provider_name.to_string(),
358 success: true,
359 error: None,
360 })
361 }
362}
363
364#[expect(
365 unused_results,
366 reason = "URL query builder methods mutate the serializer and return a fluent reference."
367)]
368fn build_auth_url(config: &McpOAuthConfig, challenge: &PkceChallenge, state: &str) -> Result<String> {
369 let mut url = Url::parse(&config.authorization_url).context("invalid oauth.authorization_url")?;
370 {
371 let mut query = url.query_pairs_mut();
372 query.append_pair("response_type", "code");
373 query.append_pair("client_id", &config.client_id);
374 query.append_pair("redirect_uri", &config.callback_url());
375 query.append_pair("code_challenge", &challenge.code_challenge);
376 query.append_pair("code_challenge_method", &challenge.code_challenge_method);
377 query.append_pair("state", state);
378 if !config.scopes.is_empty() {
379 query.append_pair("scope", &config.scopes.join(" "));
380 }
381 if let Some(audience) = config.audience.as_deref()
382 && !audience.trim().is_empty()
383 {
384 query.append_pair("audience", audience);
385 }
386 for (key, value) in &config.extra_auth_params {
387 if !key.trim().is_empty() {
388 query.append_pair(key, value);
389 }
390 }
391 }
392 Ok(url.to_string())
393}
394
395async fn exchange_code_for_token(
396 config: &McpOAuthConfig,
397 code: &str,
398 challenge: &PkceChallenge,
399) -> Result<McpOAuthToken> {
400 let mut form = vec![
401 ("grant_type".to_string(), "authorization_code".to_string()),
402 ("client_id".to_string(), config.client_id.clone()),
403 ("code".to_string(), code.to_string()),
404 ("redirect_uri".to_string(), config.callback_url()),
405 ("code_verifier".to_string(), challenge.code_verifier.to_string()),
406 ];
407 if let Some(audience) = config.audience.as_deref()
408 && !audience.trim().is_empty()
409 {
410 form.push(("audience".to_string(), audience.to_string()));
411 }
412 form.extend(
413 config
414 .extra_token_params
415 .iter()
416 .map(|(key, value)| (key.clone(), value.clone())),
417 );
418 send_token_request(&config.token_url, &form).await
419}
420
421async fn refresh_token(config: &McpOAuthConfig, current: &McpOAuthToken) -> Result<McpOAuthToken> {
422 let refresh_token = current
423 .refresh_token
424 .as_deref()
425 .filter(|value| !value.trim().is_empty())
426 .ok_or_else(|| anyhow!("Stored MCP OAuth token does not include a refresh token"))?;
427 let mut form = vec![
428 ("grant_type".to_string(), "refresh_token".to_string()),
429 ("client_id".to_string(), config.client_id.clone()),
430 ("refresh_token".to_string(), refresh_token.to_string()),
431 ];
432 if let Some(audience) = config.audience.as_deref()
433 && !audience.trim().is_empty()
434 {
435 form.push(("audience".to_string(), audience.to_string()));
436 }
437 form.extend(
438 config
439 .extra_token_params
440 .iter()
441 .map(|(key, value)| (key.clone(), value.clone())),
442 );
443
444 let refreshed = send_token_request(&config.token_url, &form).await?;
445 Ok(McpOAuthToken {
446 refresh_token: refreshed.refresh_token.or_else(|| current.refresh_token.clone()),
447 ..refreshed
448 })
449}
450
451async fn send_token_request(token_url: &str, form: &[(String, String)]) -> Result<McpOAuthToken> {
452 let token_client =
455 vtcode_commons::http::create_default_client_without_redirects().context("failed to create MCP OAuth client")?;
456 let response = vtcode_commons::http::send_with_retry_transport_only(&crate::oauth_token_retry_policy(), || {
457 token_client
458 .post(token_url)
459 .header("Content-Type", "application/x-www-form-urlencoded")
460 .form(form)
461 .send()
462 })
463 .await
464 .map_err(|error| anyhow!("failed to send MCP OAuth request: {}", error.without_url()))?;
465 let status = response.status();
466 let body = response.text().await.context("failed to read MCP OAuth response body")?;
467
468 if !status.is_success() {
469 bail!("MCP OAuth request failed (HTTP {status})");
470 }
471
472 let payload: TokenResponse = serde_json::from_str(&body).context("failed to parse MCP OAuth token response")?;
473 let now = now_secs();
474 Ok(McpOAuthToken {
475 access_token: payload.access_token,
476 refresh_token: payload.refresh_token,
477 token_type: payload.token_type,
478 scope: payload.scope,
479 obtained_at: now,
480 expires_at: payload.expires_in.map(|secs| now.saturating_add(secs)),
481 binding: None,
482 })
483}
484
485#[derive(Debug, Deserialize)]
486struct TokenResponse {
487 access_token: String,
488 #[serde(default)]
489 refresh_token: Option<String>,
490 #[serde(default)]
491 token_type: Option<String>,
492 #[serde(default)]
493 scope: Option<String>,
494 #[serde(default)]
495 expires_in: Option<u64>,
496}
497
498fn generate_state() -> Result<String> {
499 let mut state_bytes = [0_u8; 32];
500 SystemRandom::new()
501 .fill(&mut state_bytes)
502 .map_err(|_| anyhow!("failed to generate MCP OAuth state"))?;
503 Ok(URL_SAFE_NO_PAD.encode(state_bytes))
504}
505
506fn save_token(provider_name: &str, token: &McpOAuthToken, storage_mode: AuthCredentialsStoreMode) -> Result<()> {
507 let serialized = serde_json::to_string(token).context("failed to serialize MCP OAuth token")?;
508 token_storage(provider_name).store_with_mode(&serialized, storage_mode)
509}
510
511fn load_token(provider_name: &str, storage_mode: AuthCredentialsStoreMode) -> Result<Option<McpOAuthToken>> {
512 let Some(serialized) = token_storage(provider_name).load_with_mode(storage_mode)? else {
513 return Ok(None);
514 };
515 serde_json::from_str(&serialized)
516 .context("failed to parse stored MCP OAuth token")
517 .map(Some)
518}
519
520fn clear_token(provider_name: &str, storage_mode: AuthCredentialsStoreMode) -> Result<()> {
521 token_storage(provider_name).clear_with_mode(storage_mode)
522}
523
524fn token_storage(provider_name: &str) -> CredentialStorage {
525 let encoded_provider = URL_SAFE_NO_PAD.encode(provider_name.as_bytes());
526 CredentialStorage::new("vtcode", format!("mcp_oauth:v2:{encoded_provider}"))
527}
528
529fn now_secs() -> u64 {
530 std::time::SystemTime::now()
531 .duration_since(std::time::UNIX_EPOCH)
532 .map(|duration| duration.as_secs())
533 .unwrap_or(0)
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539 use assert_fs::TempDir;
540 use serial_test::serial;
541 use std::path::PathBuf;
542
543 struct TestAuthDirGuard {
544 previous: Option<PathBuf>,
545 temp_dir: Option<TempDir>,
546 }
547
548 impl TestAuthDirGuard {
549 fn new() -> Self {
550 let temp_dir = TempDir::new().expect("temp dir");
551 let previous =
552 crate::storage_paths::auth_storage_dir_override_for_tests().expect("read previous auth dir override");
553 crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(temp_dir.path().to_path_buf()))
554 .expect("set auth dir override");
555 Self { previous, temp_dir: Some(temp_dir) }
556 }
557 }
558
559 impl Drop for TestAuthDirGuard {
560 fn drop(&mut self) {
561 crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
562 .expect("restore auth dir override");
563 if let Some(temp_dir) = self.temp_dir.take() {
564 drop(temp_dir.close());
565 }
566 }
567 }
568
569 fn sample_config() -> McpOAuthConfig {
570 McpOAuthConfig {
571 authorization_url: "https://example.com/oauth/authorize".to_string(),
572 token_url: "https://example.com/oauth/token".to_string(),
573 client_id: "client-123".to_string(),
574 scopes: vec!["mcp:read".to_string(), "mcp:write".to_string()],
575 audience: Some("mcp-api".to_string()),
576 callback_port: 8123,
577 flow_timeout_secs: 120,
578 credentials_store_mode: AuthCredentialsStoreMode::File,
579 extra_auth_params: BTreeMap::from([("prompt".to_string(), "consent".to_string())]),
580 extra_token_params: BTreeMap::new(),
581 }
582 }
583
584 #[test]
585 fn validate_rejects_dangerous_endpoint_schemes() {
586 let mut config = sample_config();
587 for (field, url) in [
588 ("authorization_url", "javascript:alert(1)"),
589 ("authorization_url", "data:text/html,<script>alert(1)</script>"),
590 ("token_url", "file:///etc/passwd"),
591 ("token_url", "vbscript:msgbox(1)"),
592 ] {
593 let mut bad = config.clone();
594 if field == "authorization_url" {
595 bad.authorization_url = url.to_string();
596 } else {
597 bad.token_url = url.to_string();
598 }
599 let error = bad.validate("demo").expect_err("dangerous scheme must fail validation");
600 assert!(format!("{error:#}").contains("must be an http(s) URL"), "unexpected error for {url}: {error:#}");
601 }
602 config.token_url = "http://127.0.0.1:1/token".to_string();
603 config.validate("demo").expect("loopback http stays allowed");
604 }
605
606 #[test]
607 fn prepare_login_for_resource_rejects_dangerous_resource_scheme() {
608 let service = McpOAuthService::new();
609 let error = service
610 .prepare_login_for_resource("demo", &sample_config(), "javascript:alert(1)")
611 .expect_err("dangerous resource scheme must fail");
612 assert!(format!("{error:#}").contains("must be an http(s) URL"), "unexpected error: {error:#}");
613 }
614
615 #[test]
616 fn prepare_login_builds_expected_auth_url() {
617 let service = McpOAuthService::new();
618 let prepared = service.prepare_login("demo", &sample_config()).expect("prepare login");
619
620 assert!(prepared.auth_url.contains("response_type=code"));
621 assert!(prepared.auth_url.contains("client_id=client-123"));
622 assert!(prepared.auth_url.contains("scope=mcp%3Aread+mcp%3Awrite"));
623 assert!(prepared.auth_url.contains("audience=mcp-api"));
624 assert!(prepared.auth_url.contains("prompt=consent"));
625 assert!(prepared.auth_url.contains("code_challenge="));
626 assert!(prepared.auth_url.contains("state="));
627 assert_eq!(prepared.callback_port, 8123);
628 assert_eq!(prepared.timeout_secs, 120);
629 }
630
631 #[tokio::test]
632 async fn login_completion_rejects_changed_configuration_before_exchange() {
633 let service = McpOAuthService::new();
634 let config = sample_config();
635 let prepared = service
636 .prepare_login_for_resource("demo", &config, "https://mcp.example/api")
637 .expect("prepare");
638 let mut changed = config.clone();
639 changed.token_url = "http://127.0.0.1:1/unexpected".to_string();
640 let error = service
641 .complete_login("demo", &changed, &prepared, "code")
642 .await
643 .expect_err("reject changed config");
644 assert!(format!("{error:#}").contains("configuration changed during login"));
645 let unbound = service.prepare_login("demo", &config).expect("legacy prepare");
646 let error = service
647 .complete_login("demo", &config, &unbound, "code")
648 .await
649 .expect_err("reject unbound login");
650 assert!(format!("{error:#}").contains("requires a protected resource"));
651 }
652
653 #[tokio::test]
654 #[serial]
655 async fn bound_login_exchanges_and_resolves_for_the_same_resource() {
656 use std::io::{Read, Write};
657 let _guard = TestAuthDirGuard::new();
658 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener");
659 let address = listener.local_addr().expect("address");
660 let server = std::thread::spawn(move || {
661 let (mut stream, _) = listener.accept().expect("accept");
662 stream
663 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
664 .expect("timeout");
665 let mut buffer = [0_u8; 4096];
666 let count = stream.read(&mut buffer).expect("request");
667 assert!(String::from_utf8_lossy(&buffer[..count]).starts_with("POST /token HTTP/1.1"));
668 let body = r#"{"access_token":"fixture-access","token_type":"Bearer","expires_in":3600}"#;
669 write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).expect("response");
670 });
671 let mut config = sample_config();
672 config.token_url = format!("http://{address}/token");
673 let service = McpOAuthService::new();
674 let resource = "https://mcp.example/resource";
675 let prepared = service.prepare_login_for_resource("demo", &config, resource).expect("prepare");
676 let completion = service
677 .complete_login("demo", &config, &prepared, "fixture-code")
678 .await
679 .expect("complete");
680 assert_eq!(completion.name, "demo");
681 assert_eq!(
682 service
683 .resolve_access_token_for_resource("demo", &config, resource)
684 .await
685 .expect("resolve")
686 .as_deref(),
687 Some("fixture-access")
688 );
689 server.join().expect("server");
690 }
691
692 #[tokio::test]
693 async fn token_exchange_does_not_follow_redirects_or_echo_error_bodies() {
694 use std::io::{Read, Write};
695 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener");
696 let address = listener.local_addr().expect("address");
697 let server = std::thread::spawn(move || {
698 let (mut stream, _) = listener.accept().expect("accept");
699 let mut buffer = [0_u8; 4096];
700 let _count = stream.read(&mut buffer).expect("request");
701 let body = "sensitive-reflected-value";
702 write!(stream, "HTTP/1.1 307 Temporary Redirect\r\nLocation: http://127.0.0.1:1/steal\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).expect("response");
703 });
704 let error = send_token_request(&format!("http://{address}/token"), &[("code".into(), "secret".into())])
705 .await
706 .expect_err("redirect must fail");
707 let message = format!("{error:#}");
708 assert!(message.contains("307"), "{message}");
709 assert!(!message.contains("sensitive-reflected-value"));
710 server.join().expect("server");
711 }
712
713 #[test]
714 #[serial]
715 fn status_reflects_stored_token() {
716 let _guard = TestAuthDirGuard::new();
717 let service = McpOAuthService::new();
718 let storage_mode = AuthCredentialsStoreMode::File;
719 assert!(matches!(service.status("demo", storage_mode).expect("status"), McpOAuthStatus::NotAuthenticated));
720
721 save_token(
722 "demo",
723 &McpOAuthToken {
724 access_token: "access".to_string(),
725 refresh_token: Some("refresh".to_string()),
726 token_type: Some("Bearer".to_string()),
727 scope: Some("mcp:read".to_string()),
728 obtained_at: now_secs(),
729 expires_at: Some(now_secs() + 3600),
730 binding: Some(
731 McpOAuthBinding::new("demo", &sample_config(), "https://mcp.example/api").expect("binding"),
732 ),
733 },
734 storage_mode,
735 )
736 .expect("save token");
737
738 let status = service.status("demo", storage_mode).expect("status");
739 assert!(matches!(status, McpOAuthStatus::Authenticated { expires_in: Some(_), .. }));
740 }
741
742 #[tokio::test]
743 #[serial]
744 async fn token_resolution_requires_matching_resource_and_oauth_identity() {
745 let _guard = TestAuthDirGuard::new();
746 let service = McpOAuthService::new();
747 let config = sample_config();
748 let resource = "https://mcp.example/api";
749 let binding = McpOAuthBinding::new("demo", &config, resource).expect("binding");
750 let mut token = McpOAuthToken {
751 access_token: "access".to_string(),
752 refresh_token: Some("refresh".to_string()),
753 token_type: None,
754 scope: None,
755 obtained_at: now_secs(),
756 expires_at: None,
757 binding: Some(binding),
758 };
759 let debug = format!("{token:?}");
760 assert!(!debug.contains("\"access\""));
761 assert!(!debug.contains("\"refresh\""));
762 save_token("demo", &token, config.credentials_store_mode).expect("store token");
763 assert_eq!(
764 service
765 .resolve_access_token_for_resource("demo", &config, resource)
766 .await
767 .expect("resolve")
768 .as_deref(),
769 Some("access")
770 );
771 token.expires_at = Some(1);
773 save_token("demo", &token, config.credentials_store_mode).expect("store expired token");
774 let mut changed = config.clone();
775 changed.token_url = "https://other.example/token".to_string();
776 for (candidate, endpoint) in [(&changed, resource), (&config, "https://other.example/api")] {
777 assert_eq!(
778 service
779 .resolve_access_token_for_resource("demo", candidate, endpoint)
780 .await
781 .expect("resolve"),
782 None
783 );
784 assert!(matches!(
785 service.status_for_resource("demo", candidate, endpoint).expect("status"),
786 McpOAuthStatus::NotAuthenticated
787 ));
788 }
789 let mut changed_client = config.clone();
790 changed_client.client_id = "other-client".to_string();
791 assert_eq!(
792 service
793 .resolve_access_token_for_resource("demo", &changed_client, resource)
794 .await
795 .expect("resolve"),
796 None
797 );
798 let mut changed_auth = config.clone();
799 changed_auth.authorization_url = "https://other.example/authorize".to_string();
800 assert_eq!(
801 service
802 .resolve_access_token_for_resource("demo", &changed_auth, resource)
803 .await
804 .expect("resolve"),
805 None
806 );
807 assert_eq!(
808 service
809 .resolve_access_token_for_resource("demo_", &config, resource)
810 .await
811 .expect("resolve"),
812 None
813 );
814 token.binding = None;
815 save_token("demo", &token, config.credentials_store_mode).expect("store unbound token");
816 assert_eq!(
817 service
818 .resolve_access_token_for_resource("demo", &config, resource)
819 .await
820 .expect("resolve"),
821 None
822 );
823 }
824
825 #[test]
826 #[serial]
827 fn legacy_and_colliding_provider_names_are_not_reused() {
828 let _guard = TestAuthDirGuard::new();
829 let legacy = CredentialStorage::new("vtcode", "mcp_oauth_a_b");
830 legacy
831 .store_with_mode("old-unbound-token", AuthCredentialsStoreMode::File)
832 .expect("legacy token");
833 assert!(load_token("a/b", AuthCredentialsStoreMode::File).expect("first load").is_none());
834 assert!(
835 load_token("a_b", AuthCredentialsStoreMode::File)
836 .expect("second load")
837 .is_none()
838 );
839 let token = McpOAuthToken {
840 access_token: "first".to_string(),
841 refresh_token: None,
842 token_type: None,
843 scope: None,
844 obtained_at: now_secs(),
845 expires_at: None,
846 binding: None,
847 };
848 save_token("a/b", &token, AuthCredentialsStoreMode::File).expect("first token");
849 assert!(
850 load_token("a_b", AuthCredentialsStoreMode::File)
851 .expect("second load")
852 .is_none()
853 );
854 clear_token("a_b", AuthCredentialsStoreMode::File).expect("clear second");
855 assert_eq!(
856 load_token("a/b", AuthCredentialsStoreMode::File)
857 .expect("first load")
858 .expect("first token")
859 .access_token,
860 "first"
861 );
862 }
863
864 #[test]
865 #[serial]
866 fn logout_clears_stored_token() {
867 let _guard = TestAuthDirGuard::new();
868 let service = McpOAuthService::new();
869 let storage_mode = AuthCredentialsStoreMode::File;
870 save_token(
871 "demo",
872 &McpOAuthToken {
873 access_token: "access".to_string(),
874 refresh_token: None,
875 token_type: Some("Bearer".to_string()),
876 scope: None,
877 obtained_at: now_secs(),
878 expires_at: None,
879 binding: None,
880 },
881 storage_mode,
882 )
883 .expect("save token");
884
885 drop(service.logout("demo", storage_mode).expect("logout"));
886 assert!(load_token("demo", storage_mode).expect("load").is_none());
887 }
888}