Skip to main content

vtcode_auth/
mcp_oauth.rs

1//! OAuth support for HTTP MCP providers.
2
3use 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/// Configuration for OAuth-enabled MCP HTTP providers.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
21#[serde(default)]
22pub struct McpOAuthConfig {
23    /// OAuth authorization endpoint. Must be an http(s) URL with a host.
24    pub authorization_url: String,
25    /// OAuth token endpoint. Must be an http(s) URL with a host.
26    pub token_url: String,
27    /// OAuth client identifier.
28    pub client_id: String,
29    /// Requested scopes.
30    #[serde(default)]
31    pub scopes: Vec<String>,
32    /// Optional audience/resource hint sent with the auth and token requests.
33    #[serde(default)]
34    audience: Option<String>,
35    /// Local callback server port.
36    pub callback_port: u16,
37    /// Browser-flow timeout in seconds.
38    flow_timeout_secs: u64,
39    /// Credential storage backend for this provider's token.
40    #[serde(default)]
41    pub credentials_store_mode: AuthCredentialsStoreMode,
42    /// Extra query parameters appended to the authorization URL.
43    #[serde(default)]
44    extra_auth_params: BTreeMap<String, String>,
45    /// Extra form fields appended to token exchanges and refreshes.
46    #[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
67/// Allowlist-based endpoint check: only `http(s)` URLs with a host.
68///
69/// Configured OAuth endpoints and the protected resource all pass through
70/// here so a `javascript:`/`data:`/shell-metachar URL fails at prepare time,
71/// never at `webbrowser::open` or `reqwest` time.
72fn 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/// Stored OAuth token for an MCP HTTP provider.
102#[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/// Status for an MCP provider's stored OAuth token.
168#[derive(Debug, Clone)]
169pub enum McpOAuthStatus {
170    Authenticated { age_seconds: u64, expires_in: Option<u64> },
171    NotAuthenticated,
172}
173
174/// Prepared browser-login flow for an MCP OAuth provider.
175#[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/// Completion payload kept intentionally close to Codex app-server.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct McpOAuthLoginCompletion {
195    pub name: String,
196    success: bool,
197    error: Option<String>,
198}
199
200/// Service for loading, refreshing, and persisting MCP OAuth tokens.
201#[derive(Debug, Clone, Default)]
202pub struct McpOAuthService;
203
204impl McpOAuthService {
205    #[must_use]
206    pub fn new() -> Self {
207        Self
208    }
209
210    /// Prepare the authorization URL without a resource binding.
211    ///
212    /// Such a flow cannot be completed securely; use `prepare_login_for_resource`
213    /// when the token will be used by an HTTP MCP provider.
214    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    /// Prepare a login bound to the HTTP resource that will receive the token.
230    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    /// Report authentication only when the stored binding matches the configured resource.
281    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    /// Unbound resolution is rejected because it cannot identify the receiving resource.
310    #[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    /// Resolve only a token bound to the current authorization and resource configuration.
320    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    // Bounded by the installed HTTP defaults: a token endpoint that accepts the
453    // connection and then stalls must not block session startup forever.
454    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        // A mismatched expired token must be rejected before any refresh request.
772        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}