1use anyhow::{Context, Result, anyhow, bail};
4use base64::Engine;
5use base64::engine::general_purpose::URL_SAFE_NO_PAD;
6use reqwest::{Client, 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 response = Client::builder()
453 .redirect(reqwest::redirect::Policy::none())
454 .build()
455 .context("failed to create MCP OAuth client")?
456 .post(token_url)
457 .header("Content-Type", "application/x-www-form-urlencoded")
458 .form(form)
459 .send()
460 .await
461 .map_err(|error| anyhow!("failed to send MCP OAuth request: {}", error.without_url()))?;
462 let status = response.status();
463 let body = response.text().await.context("failed to read MCP OAuth response body")?;
464
465 if !status.is_success() {
466 bail!("MCP OAuth request failed (HTTP {status})");
467 }
468
469 let payload: TokenResponse = serde_json::from_str(&body).context("failed to parse MCP OAuth token response")?;
470 let now = now_secs();
471 Ok(McpOAuthToken {
472 access_token: payload.access_token,
473 refresh_token: payload.refresh_token,
474 token_type: payload.token_type,
475 scope: payload.scope,
476 obtained_at: now,
477 expires_at: payload.expires_in.map(|secs| now.saturating_add(secs)),
478 binding: None,
479 })
480}
481
482#[derive(Debug, Deserialize)]
483struct TokenResponse {
484 access_token: String,
485 #[serde(default)]
486 refresh_token: Option<String>,
487 #[serde(default)]
488 token_type: Option<String>,
489 #[serde(default)]
490 scope: Option<String>,
491 #[serde(default)]
492 expires_in: Option<u64>,
493}
494
495fn generate_state() -> Result<String> {
496 let mut state_bytes = [0_u8; 32];
497 SystemRandom::new()
498 .fill(&mut state_bytes)
499 .map_err(|_| anyhow!("failed to generate MCP OAuth state"))?;
500 Ok(URL_SAFE_NO_PAD.encode(state_bytes))
501}
502
503fn save_token(provider_name: &str, token: &McpOAuthToken, storage_mode: AuthCredentialsStoreMode) -> Result<()> {
504 let serialized = serde_json::to_string(token).context("failed to serialize MCP OAuth token")?;
505 token_storage(provider_name).store_with_mode(&serialized, storage_mode)
506}
507
508fn load_token(provider_name: &str, storage_mode: AuthCredentialsStoreMode) -> Result<Option<McpOAuthToken>> {
509 let Some(serialized) = token_storage(provider_name).load_with_mode(storage_mode)? else {
510 return Ok(None);
511 };
512 serde_json::from_str(&serialized)
513 .context("failed to parse stored MCP OAuth token")
514 .map(Some)
515}
516
517fn clear_token(provider_name: &str, storage_mode: AuthCredentialsStoreMode) -> Result<()> {
518 token_storage(provider_name).clear_with_mode(storage_mode)
519}
520
521fn token_storage(provider_name: &str) -> CredentialStorage {
522 let encoded_provider = URL_SAFE_NO_PAD.encode(provider_name.as_bytes());
523 CredentialStorage::new("vtcode", format!("mcp_oauth:v2:{encoded_provider}"))
524}
525
526fn now_secs() -> u64 {
527 std::time::SystemTime::now()
528 .duration_since(std::time::UNIX_EPOCH)
529 .map(|duration| duration.as_secs())
530 .unwrap_or(0)
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use assert_fs::TempDir;
537 use serial_test::serial;
538 use std::path::PathBuf;
539
540 struct TestAuthDirGuard {
541 previous: Option<PathBuf>,
542 temp_dir: Option<TempDir>,
543 }
544
545 impl TestAuthDirGuard {
546 fn new() -> Self {
547 let temp_dir = TempDir::new().expect("temp dir");
548 let previous =
549 crate::storage_paths::auth_storage_dir_override_for_tests().expect("read previous auth dir override");
550 crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(temp_dir.path().to_path_buf()))
551 .expect("set auth dir override");
552 Self { previous, temp_dir: Some(temp_dir) }
553 }
554 }
555
556 impl Drop for TestAuthDirGuard {
557 fn drop(&mut self) {
558 crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
559 .expect("restore auth dir override");
560 if let Some(temp_dir) = self.temp_dir.take() {
561 drop(temp_dir.close());
562 }
563 }
564 }
565
566 fn sample_config() -> McpOAuthConfig {
567 McpOAuthConfig {
568 authorization_url: "https://example.com/oauth/authorize".to_string(),
569 token_url: "https://example.com/oauth/token".to_string(),
570 client_id: "client-123".to_string(),
571 scopes: vec!["mcp:read".to_string(), "mcp:write".to_string()],
572 audience: Some("mcp-api".to_string()),
573 callback_port: 8123,
574 flow_timeout_secs: 120,
575 credentials_store_mode: AuthCredentialsStoreMode::File,
576 extra_auth_params: BTreeMap::from([("prompt".to_string(), "consent".to_string())]),
577 extra_token_params: BTreeMap::new(),
578 }
579 }
580
581 #[test]
582 fn validate_rejects_dangerous_endpoint_schemes() {
583 let mut config = sample_config();
584 for (field, url) in [
585 ("authorization_url", "javascript:alert(1)"),
586 ("authorization_url", "data:text/html,<script>alert(1)</script>"),
587 ("token_url", "file:///etc/passwd"),
588 ("token_url", "vbscript:msgbox(1)"),
589 ] {
590 let mut bad = config.clone();
591 if field == "authorization_url" {
592 bad.authorization_url = url.to_string();
593 } else {
594 bad.token_url = url.to_string();
595 }
596 let error = bad.validate("demo").expect_err("dangerous scheme must fail validation");
597 assert!(format!("{error:#}").contains("must be an http(s) URL"), "unexpected error for {url}: {error:#}");
598 }
599 config.token_url = "http://127.0.0.1:1/token".to_string();
600 config.validate("demo").expect("loopback http stays allowed");
601 }
602
603 #[test]
604 fn prepare_login_for_resource_rejects_dangerous_resource_scheme() {
605 let service = McpOAuthService::new();
606 let error = service
607 .prepare_login_for_resource("demo", &sample_config(), "javascript:alert(1)")
608 .expect_err("dangerous resource scheme must fail");
609 assert!(format!("{error:#}").contains("must be an http(s) URL"), "unexpected error: {error:#}");
610 }
611
612 #[test]
613 fn prepare_login_builds_expected_auth_url() {
614 let service = McpOAuthService::new();
615 let prepared = service.prepare_login("demo", &sample_config()).expect("prepare login");
616
617 assert!(prepared.auth_url.contains("response_type=code"));
618 assert!(prepared.auth_url.contains("client_id=client-123"));
619 assert!(prepared.auth_url.contains("scope=mcp%3Aread+mcp%3Awrite"));
620 assert!(prepared.auth_url.contains("audience=mcp-api"));
621 assert!(prepared.auth_url.contains("prompt=consent"));
622 assert!(prepared.auth_url.contains("code_challenge="));
623 assert!(prepared.auth_url.contains("state="));
624 assert_eq!(prepared.callback_port, 8123);
625 assert_eq!(prepared.timeout_secs, 120);
626 }
627
628 #[tokio::test]
629 async fn login_completion_rejects_changed_configuration_before_exchange() {
630 let service = McpOAuthService::new();
631 let config = sample_config();
632 let prepared = service
633 .prepare_login_for_resource("demo", &config, "https://mcp.example/api")
634 .expect("prepare");
635 let mut changed = config.clone();
636 changed.token_url = "http://127.0.0.1:1/unexpected".to_string();
637 let error = service
638 .complete_login("demo", &changed, &prepared, "code")
639 .await
640 .expect_err("reject changed config");
641 assert!(format!("{error:#}").contains("configuration changed during login"));
642 let unbound = service.prepare_login("demo", &config).expect("legacy prepare");
643 let error = service
644 .complete_login("demo", &config, &unbound, "code")
645 .await
646 .expect_err("reject unbound login");
647 assert!(format!("{error:#}").contains("requires a protected resource"));
648 }
649
650 #[tokio::test]
651 #[serial]
652 async fn bound_login_exchanges_and_resolves_for_the_same_resource() {
653 use std::io::{Read, Write};
654 let _guard = TestAuthDirGuard::new();
655 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener");
656 let address = listener.local_addr().expect("address");
657 let server = std::thread::spawn(move || {
658 let (mut stream, _) = listener.accept().expect("accept");
659 stream
660 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
661 .expect("timeout");
662 let mut buffer = [0_u8; 4096];
663 let count = stream.read(&mut buffer).expect("request");
664 assert!(String::from_utf8_lossy(&buffer[..count]).starts_with("POST /token HTTP/1.1"));
665 let body = r#"{"access_token":"fixture-access","token_type":"Bearer","expires_in":3600}"#;
666 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");
667 });
668 let mut config = sample_config();
669 config.token_url = format!("http://{address}/token");
670 let service = McpOAuthService::new();
671 let resource = "https://mcp.example/resource";
672 let prepared = service.prepare_login_for_resource("demo", &config, resource).expect("prepare");
673 let completion = service
674 .complete_login("demo", &config, &prepared, "fixture-code")
675 .await
676 .expect("complete");
677 assert_eq!(completion.name, "demo");
678 assert_eq!(
679 service
680 .resolve_access_token_for_resource("demo", &config, resource)
681 .await
682 .expect("resolve")
683 .as_deref(),
684 Some("fixture-access")
685 );
686 server.join().expect("server");
687 }
688
689 #[tokio::test]
690 async fn token_exchange_does_not_follow_redirects_or_echo_error_bodies() {
691 use std::io::{Read, Write};
692 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener");
693 let address = listener.local_addr().expect("address");
694 let server = std::thread::spawn(move || {
695 let (mut stream, _) = listener.accept().expect("accept");
696 let mut buffer = [0_u8; 4096];
697 let _count = stream.read(&mut buffer).expect("request");
698 let body = "sensitive-reflected-value";
699 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");
700 });
701 let error = send_token_request(&format!("http://{address}/token"), &[("code".into(), "secret".into())])
702 .await
703 .expect_err("redirect must fail");
704 let message = format!("{error:#}");
705 assert!(message.contains("307"), "{message}");
706 assert!(!message.contains("sensitive-reflected-value"));
707 server.join().expect("server");
708 }
709
710 #[test]
711 #[serial]
712 fn status_reflects_stored_token() {
713 let _guard = TestAuthDirGuard::new();
714 let service = McpOAuthService::new();
715 let storage_mode = AuthCredentialsStoreMode::File;
716 assert!(matches!(service.status("demo", storage_mode).expect("status"), McpOAuthStatus::NotAuthenticated));
717
718 save_token(
719 "demo",
720 &McpOAuthToken {
721 access_token: "access".to_string(),
722 refresh_token: Some("refresh".to_string()),
723 token_type: Some("Bearer".to_string()),
724 scope: Some("mcp:read".to_string()),
725 obtained_at: now_secs(),
726 expires_at: Some(now_secs() + 3600),
727 binding: Some(
728 McpOAuthBinding::new("demo", &sample_config(), "https://mcp.example/api").expect("binding"),
729 ),
730 },
731 storage_mode,
732 )
733 .expect("save token");
734
735 let status = service.status("demo", storage_mode).expect("status");
736 assert!(matches!(status, McpOAuthStatus::Authenticated { expires_in: Some(_), .. }));
737 }
738
739 #[tokio::test]
740 #[serial]
741 async fn token_resolution_requires_matching_resource_and_oauth_identity() {
742 let _guard = TestAuthDirGuard::new();
743 let service = McpOAuthService::new();
744 let config = sample_config();
745 let resource = "https://mcp.example/api";
746 let binding = McpOAuthBinding::new("demo", &config, resource).expect("binding");
747 let mut token = McpOAuthToken {
748 access_token: "access".to_string(),
749 refresh_token: Some("refresh".to_string()),
750 token_type: None,
751 scope: None,
752 obtained_at: now_secs(),
753 expires_at: None,
754 binding: Some(binding),
755 };
756 let debug = format!("{token:?}");
757 assert!(!debug.contains("\"access\""));
758 assert!(!debug.contains("\"refresh\""));
759 save_token("demo", &token, config.credentials_store_mode).expect("store token");
760 assert_eq!(
761 service
762 .resolve_access_token_for_resource("demo", &config, resource)
763 .await
764 .expect("resolve")
765 .as_deref(),
766 Some("access")
767 );
768 token.expires_at = Some(1);
770 save_token("demo", &token, config.credentials_store_mode).expect("store expired token");
771 let mut changed = config.clone();
772 changed.token_url = "https://other.example/token".to_string();
773 for (candidate, endpoint) in [(&changed, resource), (&config, "https://other.example/api")] {
774 assert_eq!(
775 service
776 .resolve_access_token_for_resource("demo", candidate, endpoint)
777 .await
778 .expect("resolve"),
779 None
780 );
781 assert!(matches!(
782 service.status_for_resource("demo", candidate, endpoint).expect("status"),
783 McpOAuthStatus::NotAuthenticated
784 ));
785 }
786 let mut changed_client = config.clone();
787 changed_client.client_id = "other-client".to_string();
788 assert_eq!(
789 service
790 .resolve_access_token_for_resource("demo", &changed_client, resource)
791 .await
792 .expect("resolve"),
793 None
794 );
795 let mut changed_auth = config.clone();
796 changed_auth.authorization_url = "https://other.example/authorize".to_string();
797 assert_eq!(
798 service
799 .resolve_access_token_for_resource("demo", &changed_auth, resource)
800 .await
801 .expect("resolve"),
802 None
803 );
804 assert_eq!(
805 service
806 .resolve_access_token_for_resource("demo_", &config, resource)
807 .await
808 .expect("resolve"),
809 None
810 );
811 token.binding = None;
812 save_token("demo", &token, config.credentials_store_mode).expect("store unbound token");
813 assert_eq!(
814 service
815 .resolve_access_token_for_resource("demo", &config, resource)
816 .await
817 .expect("resolve"),
818 None
819 );
820 }
821
822 #[test]
823 #[serial]
824 fn legacy_and_colliding_provider_names_are_not_reused() {
825 let _guard = TestAuthDirGuard::new();
826 let legacy = CredentialStorage::new("vtcode", "mcp_oauth_a_b");
827 legacy
828 .store_with_mode("old-unbound-token", AuthCredentialsStoreMode::File)
829 .expect("legacy token");
830 assert!(load_token("a/b", AuthCredentialsStoreMode::File).expect("first load").is_none());
831 assert!(
832 load_token("a_b", AuthCredentialsStoreMode::File)
833 .expect("second load")
834 .is_none()
835 );
836 let token = McpOAuthToken {
837 access_token: "first".to_string(),
838 refresh_token: None,
839 token_type: None,
840 scope: None,
841 obtained_at: now_secs(),
842 expires_at: None,
843 binding: None,
844 };
845 save_token("a/b", &token, AuthCredentialsStoreMode::File).expect("first token");
846 assert!(
847 load_token("a_b", AuthCredentialsStoreMode::File)
848 .expect("second load")
849 .is_none()
850 );
851 clear_token("a_b", AuthCredentialsStoreMode::File).expect("clear second");
852 assert_eq!(
853 load_token("a/b", AuthCredentialsStoreMode::File)
854 .expect("first load")
855 .expect("first token")
856 .access_token,
857 "first"
858 );
859 }
860
861 #[test]
862 #[serial]
863 fn logout_clears_stored_token() {
864 let _guard = TestAuthDirGuard::new();
865 let service = McpOAuthService::new();
866 let storage_mode = AuthCredentialsStoreMode::File;
867 save_token(
868 "demo",
869 &McpOAuthToken {
870 access_token: "access".to_string(),
871 refresh_token: None,
872 token_type: Some("Bearer".to_string()),
873 scope: None,
874 obtained_at: now_secs(),
875 expires_at: None,
876 binding: None,
877 },
878 storage_mode,
879 )
880 .expect("save token");
881
882 drop(service.logout("demo", storage_mode).expect("logout"));
883 assert!(load_token("demo", storage_mode).expect("load").is_none());
884 }
885}