Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T01:50:23.258Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

oauth.rs

2863 lines · 105.3 KB · rust
1//! OAuth 2.0 authentication for MCP servers using the Authorization Code +
2//! PKCE flow, per the MCP spec's OAuth profile.
3//!
4//! The flow is split into two phases:
5//!
6//! 1. **Discovery** ([`discover`]) fetches Protected Resource Metadata and
7//!    Authorization Server Metadata. This can happen early (e.g. on a 401
8//!    during server startup) because it doesn't need the redirect URI yet.
9//!
10//! 2. **Client registration** ([`resolve_client_registration`]) is separate
11//!    because DCR requires the actual loopback redirect URI, which includes an
12//!    ephemeral port that only exists once the callback server has started.
13//!
14//! After authentication, the full state is captured in [`OAuthSession`] which
15//! is persisted to the keychain. On next startup, the stored session feeds
16//! directly into [`McpOAuthTokenProvider`], giving a refresh-capable provider
17//! without requiring another browser flow.
18
19use anyhow::{Context as _, Result, anyhow, bail};
20use async_trait::async_trait;
21use base64::Engine as _;
22use futures::AsyncReadExt as _;
23use futures::FutureExt as _;
24use futures::channel::mpsc;
25use futures::future::BoxFuture;
26use http_client::{AsyncBody, HttpClient, Request};
27use parking_lot::Mutex as SyncMutex;
28use rand::Rng as _;
29use serde::{Deserialize, Serialize};
30use sha2::{Digest, Sha256};
31
32use std::sync::Arc;
33use std::time::{Duration, SystemTime};
34use url::Url;
35
36/// The CIMD URL where Zed's OAuth client metadata document is hosted.
37pub const CIMD_URL: &str = "https://zed.dev/oauth/client-metadata.json";
38
39/// Validate that a URL is safe to use as an OAuth endpoint.
40///
41/// OAuth endpoints carry sensitive material (authorization codes, PKCE
42/// verifiers, tokens) and must use TLS. Plain HTTP is only permitted for
43/// loopback addresses, per RFC 8252 Section 8.3.
44fn require_https_or_loopback(url: &Url) -> Result<()> {
45    if url.scheme() == "https" {
46        return Ok(());
47    }
48    if url.scheme() == "http" {
49        if let Some(host) = url.host() {
50            match host {
51                url::Host::Ipv4(ip) if ip.is_loopback() => return Ok(()),
52                url::Host::Ipv6(ip) if ip.is_loopback() => return Ok(()),
53                url::Host::Domain(d) if d.eq_ignore_ascii_case("localhost") => return Ok(()),
54                _ => {}
55            }
56        }
57    }
58    bail!(
59        "OAuth endpoint must use HTTPS (got {}://{})",
60        url.scheme(),
61        url.host_str().unwrap_or("?")
62    )
63}
64
65/// Validate that a URL is safe to use as an OAuth endpoint, including SSRF
66/// protections against private/reserved IP ranges.
67///
68/// This wraps [`require_https_or_loopback`] and adds IP-range checks to prevent
69/// an attacker-controlled MCP server from directing Zed to fetch internal
70/// network resources via metadata URLs.
71///
72/// **Known limitation:** Domain-name URLs that resolve to private IPs are *not*
73/// blocked here — full mitigation requires resolver-level validation (e.g. a
74/// custom `Resolve` implementation). This function only blocks IP-literal URLs.
75fn validate_oauth_url(url: &Url) -> Result<()> {
76    require_https_or_loopback(url)?;
77
78    if let Some(host) = url.host() {
79        match host {
80            url::Host::Ipv4(ip) => {
81                // Loopback is already allowed by require_https_or_loopback.
82                if ip.is_private() || ip.is_link_local() || ip.is_broadcast() || ip.is_unspecified()
83                {
84                    bail!(
85                        "OAuth endpoint must not point to private/reserved IP: {}",
86                        ip
87                    );
88                }
89            }
90            url::Host::Ipv6(ip) => {
91                // Check for IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) which
92                // could bypass the IPv4 checks above.
93                if let Some(mapped_v4) = ip.to_ipv4_mapped() {
94                    if mapped_v4.is_private()
95                        || mapped_v4.is_link_local()
96                        || mapped_v4.is_broadcast()
97                        || mapped_v4.is_unspecified()
98                    {
99                        bail!(
100                            "OAuth endpoint must not point to private/reserved IP: ::ffff:{}",
101                            mapped_v4
102                        );
103                    }
104                }
105
106                if ip.is_unspecified() || ip.is_multicast() {
107                    bail!(
108                        "OAuth endpoint must not point to reserved IPv6 address: {}",
109                        ip
110                    );
111                }
112                // IPv6 Unique Local Addresses (fc00::/7). is_unique_local() is
113                // nightly-only, so check the prefix manually.
114                if (ip.segments()[0] & 0xfe00) == 0xfc00 {
115                    bail!(
116                        "OAuth endpoint must not point to IPv6 unique-local address: {}",
117                        ip
118                    );
119                }
120            }
121            url::Host::Domain(_) => {
122                // Domain-based SSRF prevention requires resolver-level checks.
123                // See known limitation in the doc comment above.
124            }
125        }
126    }
127
128    Ok(())
129}
130
131/// Parsed from the MCP server's WWW-Authenticate header or well-known endpoint
132/// per RFC 9728 (OAuth 2.0 Protected Resource Metadata).
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ProtectedResourceMetadata {
135    pub resource: Url,
136    pub authorization_servers: Vec<Url>,
137    pub scopes_supported: Option<Vec<String>>,
138}
139
140/// Parsed from the authorization server's .well-known endpoint
141/// per RFC 8414 (OAuth 2.0 Authorization Server Metadata).
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct AuthServerMetadata {
144    pub issuer: Url,
145    pub authorization_endpoint: Url,
146    pub token_endpoint: Url,
147    pub registration_endpoint: Option<Url>,
148    pub scopes_supported: Option<Vec<String>>,
149    pub grant_types_supported: Option<Vec<String>>,
150    pub code_challenge_methods_supported: Option<Vec<String>>,
151    pub client_id_metadata_document_supported: bool,
152}
153
154/// The result of client registration — either CIMD or DCR.
155#[derive(Clone, Serialize, Deserialize)]
156pub struct OAuthClientRegistration {
157    pub client_id: String,
158    /// Only present for DCR-minted registrations.
159    pub client_secret: Option<String>,
160}
161
162impl std::fmt::Debug for OAuthClientRegistration {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        f.debug_struct("OAuthClientRegistration")
165            .field("client_id", &self.client_id)
166            .field(
167                "client_secret",
168                &self.client_secret.as_ref().map(|_| "[redacted]"),
169            )
170            .finish()
171    }
172}
173
174/// Access and refresh tokens obtained from the token endpoint.
175#[derive(Clone, Serialize, Deserialize)]
176pub struct OAuthTokens {
177    pub access_token: String,
178    pub refresh_token: Option<String>,
179    pub expires_at: Option<SystemTime>,
180}
181
182impl std::fmt::Debug for OAuthTokens {
183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        f.debug_struct("OAuthTokens")
185            .field("access_token", &"[redacted]")
186            .field(
187                "refresh_token",
188                &self.refresh_token.as_ref().map(|_| "[redacted]"),
189            )
190            .field("expires_at", &self.expires_at)
191            .finish()
192    }
193}
194
195/// Everything discovered before the browser flow starts. Client registration is
196/// resolved separately, once the real redirect URI is known.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct OAuthDiscovery {
199    pub resource_metadata: ProtectedResourceMetadata,
200    pub auth_server_metadata: AuthServerMetadata,
201    pub scopes: Vec<String>,
202}
203
204/// The persisted OAuth session for a context server.
205///
206/// Stored in the keychain so startup can restore a refresh-capable provider
207/// without another browser flow. Deliberately excludes the full discovery
208/// metadata to keep the serialized size well within keychain item limits.
209#[derive(Clone, Serialize, Deserialize)]
210pub struct OAuthSession {
211    pub token_endpoint: Url,
212    pub resource: Url,
213    pub client_registration: OAuthClientRegistration,
214    pub tokens: OAuthTokens,
215}
216
217impl std::fmt::Debug for OAuthSession {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        f.debug_struct("OAuthSession")
220            .field("token_endpoint", &self.token_endpoint)
221            .field("resource", &self.resource)
222            .field("client_registration", &self.client_registration)
223            .field("tokens", &self.tokens)
224            .finish()
225    }
226}
227
228/// Error codes defined by RFC 6750 Section 3.1 for Bearer token authentication.
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub enum BearerError {
231    /// The request is missing a required parameter, includes an unsupported
232    /// parameter or parameter value, or is otherwise malformed.
233    InvalidRequest,
234    /// The access token provided is expired, revoked, malformed, or invalid.
235    InvalidToken,
236    /// The request requires higher privileges than provided by the access token.
237    InsufficientScope,
238    /// An unrecognized error code (extension or future spec addition).
239    Other,
240}
241
242impl BearerError {
243    fn parse(value: &str) -> Self {
244        match value {
245            "invalid_request" => BearerError::InvalidRequest,
246            "invalid_token" => BearerError::InvalidToken,
247            "insufficient_scope" => BearerError::InsufficientScope,
248            _ => BearerError::Other,
249        }
250    }
251}
252
253/// Fields extracted from a `WWW-Authenticate: Bearer` header.
254///
255/// Per RFC 9728 Section 5.1, MCP servers include `resource_metadata` to point
256/// at the Protected Resource Metadata document. The optional `scope` parameter
257/// (RFC 6750 Section 3) indicates scopes required for the request.
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct WwwAuthenticate {
260    pub resource_metadata: Option<Url>,
261    pub scope: Option<Vec<String>>,
262    /// The parsed `error` parameter per RFC 6750 Section 3.1.
263    pub error: Option<BearerError>,
264    pub error_description: Option<String>,
265}
266
267/// Parse a `WWW-Authenticate` header value.
268///
269/// Expects the `Bearer` scheme followed by comma-separated `key="value"` pairs.
270/// Per RFC 6750 and RFC 9728, the relevant parameters are:
271/// - `resource_metadata` — URL of the Protected Resource Metadata document
272/// - `scope` — space-separated list of required scopes
273/// - `error` — error code (e.g. "insufficient_scope")
274/// - `error_description` — human-readable error description
275pub fn parse_www_authenticate(header: &str) -> Result<WwwAuthenticate> {
276    let header = header.trim();
277
278    let params_str = if header.len() >= 6 && header[..6].eq_ignore_ascii_case("bearer") {
279        header[6..].trim()
280    } else {
281        bail!("WWW-Authenticate header does not use Bearer scheme");
282    };
283
284    if params_str.is_empty() {
285        return Ok(WwwAuthenticate {
286            resource_metadata: None,
287            scope: None,
288            error: None,
289            error_description: None,
290        });
291    }
292
293    let params = parse_auth_params(params_str);
294
295    let resource_metadata = params
296        .get("resource_metadata")
297        .map(|v| Url::parse(v))
298        .transpose()
299        .map_err(|e| anyhow!("invalid resource_metadata URL: {}", e))?;
300
301    let scope = params
302        .get("scope")
303        .map(|v| v.split_whitespace().map(String::from).collect());
304
305    let error = params.get("error").map(|v| BearerError::parse(v));
306    let error_description = params.get("error_description").cloned();
307
308    Ok(WwwAuthenticate {
309        resource_metadata,
310        scope,
311        error,
312        error_description,
313    })
314}
315
316/// Parse comma-separated `key="value"` or `key=token` parameters from an
317/// auth-param list (RFC 7235 Section 2.1).
318fn parse_auth_params(input: &str) -> collections::HashMap<String, String> {
319    let mut params = collections::HashMap::default();
320    let mut remaining = input.trim();
321
322    while !remaining.is_empty() {
323        // Skip leading whitespace and commas.
324        remaining = remaining.trim_start_matches(|c: char| c == ',' || c.is_whitespace());
325        if remaining.is_empty() {
326            break;
327        }
328
329        // Find the key (everything before '=').
330        let eq_pos = match remaining.find('=') {
331            Some(pos) => pos,
332            None => break,
333        };
334
335        let key = remaining[..eq_pos].trim().to_lowercase();
336        remaining = &remaining[eq_pos + 1..];
337        remaining = remaining.trim_start();
338
339        // Parse the value: either quoted or unquoted (token).
340        let value;
341        if remaining.starts_with('"') {
342            // Quoted string: find the closing quote, handling escaped chars.
343            remaining = &remaining[1..]; // skip opening quote
344            let mut val = String::new();
345            let mut chars = remaining.char_indices();
346            loop {
347                match chars.next() {
348                    Some((_, '\\')) => {
349                        // Escaped character — take the next char literally.
350                        if let Some((_, c)) = chars.next() {
351                            val.push(c);
352                        }
353                    }
354                    Some((i, '"')) => {
355                        remaining = &remaining[i + 1..];
356                        break;
357                    }
358                    Some((_, c)) => val.push(c),
359                    None => {
360                        remaining = "";
361                        break;
362                    }
363                }
364            }
365            value = val;
366        } else {
367            // Unquoted token: read until comma or whitespace.
368            let end = remaining
369                .find(|c: char| c == ',' || c.is_whitespace())
370                .unwrap_or(remaining.len());
371            value = remaining[..end].to_string();
372            remaining = &remaining[end..];
373        }
374
375        if !key.is_empty() {
376            params.insert(key, value);
377        }
378    }
379
380    params
381}
382
383/// Construct the well-known Protected Resource Metadata URIs for a given MCP
384/// server URL, per RFC 9728 Section 3.
385///
386/// Returns URIs in priority order:
387/// 1. Path-specific: `https://<host>/.well-known/oauth-protected-resource/<path>`
388/// 2. Root: `https://<host>/.well-known/oauth-protected-resource`
389pub fn protected_resource_metadata_urls(server_url: &Url) -> Vec<Url> {
390    let mut urls = Vec::new();
391    let base = format!("{}://{}", server_url.scheme(), server_url.authority());
392
393    let path = server_url.path().trim_start_matches('/');
394    if !path.is_empty() {
395        if let Ok(url) = Url::parse(&format!(
396            "{}/.well-known/oauth-protected-resource/{}",
397            base, path
398        )) {
399            urls.push(url);
400        }
401    }
402
403    if let Ok(url) = Url::parse(&format!("{}/.well-known/oauth-protected-resource", base)) {
404        urls.push(url);
405    }
406
407    urls
408}
409
410/// Construct the well-known Authorization Server Metadata URIs for a given
411/// issuer URL, per RFC 8414 Section 3.1 and Section 5 (OIDC compat).
412///
413/// Returns URIs in priority order, which differs depending on whether the
414/// issuer URL has a path component.
415pub fn auth_server_metadata_urls(issuer: &Url) -> Vec<Url> {
416    let mut urls = Vec::new();
417    let base = format!("{}://{}", issuer.scheme(), issuer.authority());
418    let path = issuer.path().trim_matches('/');
419
420    if !path.is_empty() {
421        // Issuer with path: try path-inserted variants first.
422        if let Ok(url) = Url::parse(&format!(
423            "{}/.well-known/oauth-authorization-server/{}",
424            base, path
425        )) {
426            urls.push(url);
427        }
428        if let Ok(url) = Url::parse(&format!(
429            "{}/.well-known/openid-configuration/{}",
430            base, path
431        )) {
432            urls.push(url);
433        }
434        if let Ok(url) = Url::parse(&format!(
435            "{}/{}/.well-known/openid-configuration",
436            base, path
437        )) {
438            urls.push(url);
439        }
440    } else {
441        // No path: standard well-known locations.
442        if let Ok(url) = Url::parse(&format!("{}/.well-known/oauth-authorization-server", base)) {
443            urls.push(url);
444        }
445        if let Ok(url) = Url::parse(&format!("{}/.well-known/openid-configuration", base)) {
446            urls.push(url);
447        }
448    }
449
450    urls
451}
452
453// -- Canonical server URI (RFC 8707) -----------------------------------------
454
455/// Derive the canonical resource URI for an MCP server URL, suitable for the
456/// `resource` parameter in authorization and token requests per RFC 8707.
457///
458/// Lowercases the scheme and host, preserves the path (without trailing slash),
459/// strips fragments and query strings.
460pub fn canonical_server_uri(server_url: &Url) -> String {
461    let mut uri = format!(
462        "{}://{}",
463        server_url.scheme().to_ascii_lowercase(),
464        server_url.host_str().unwrap_or("").to_ascii_lowercase(),
465    );
466    if let Some(port) = server_url.port() {
467        uri.push_str(&format!(":{}", port));
468    }
469    let path = server_url.path();
470    if path != "/" {
471        uri.push_str(path.trim_end_matches('/'));
472    }
473    uri
474}
475
476// -- Scope selection ---------------------------------------------------------
477
478/// Select scopes following the MCP spec's Scope Selection Strategy:
479/// 1. Use `scope` from the `WWW-Authenticate` challenge if present.
480/// 2. Fall back to `scopes_supported` from Protected Resource Metadata.
481/// 3. Return empty if neither is available.
482pub fn select_scopes(
483    www_authenticate: &WwwAuthenticate,
484    resource_metadata: &ProtectedResourceMetadata,
485) -> Vec<String> {
486    if let Some(ref scopes) = www_authenticate.scope {
487        if !scopes.is_empty() {
488            return scopes.clone();
489        }
490    }
491    resource_metadata
492        .scopes_supported
493        .clone()
494        .unwrap_or_default()
495}
496
497// -- Client registration strategy --------------------------------------------
498
499/// The registration approach to use, determined from auth server metadata.
500#[derive(Debug, Clone, PartialEq, Eq)]
501pub enum ClientRegistrationStrategy {
502    /// The auth server supports CIMD. Use the CIMD URL as client_id directly.
503    Cimd { client_id: String },
504    /// The auth server has a registration endpoint. Caller must POST to it.
505    Dcr { registration_endpoint: Url },
506    /// No supported registration mechanism.
507    Unavailable,
508}
509
510/// Determine how to register with the authorization server, following the
511/// spec's recommended priority: CIMD first, DCR fallback.
512pub fn determine_registration_strategy(
513    auth_server_metadata: &AuthServerMetadata,
514) -> ClientRegistrationStrategy {
515    if auth_server_metadata.client_id_metadata_document_supported {
516        ClientRegistrationStrategy::Cimd {
517            client_id: CIMD_URL.to_string(),
518        }
519    } else if let Some(ref endpoint) = auth_server_metadata.registration_endpoint {
520        ClientRegistrationStrategy::Dcr {
521            registration_endpoint: endpoint.clone(),
522        }
523    } else {
524        ClientRegistrationStrategy::Unavailable
525    }
526}
527
528// -- PKCE (RFC 7636) ---------------------------------------------------------
529
530/// A PKCE code verifier and its S256 challenge.
531#[derive(Clone)]
532pub struct PkceChallenge {
533    pub verifier: String,
534    pub challenge: String,
535}
536
537impl std::fmt::Debug for PkceChallenge {
538    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
539        f.debug_struct("PkceChallenge")
540            .field("verifier", &"[redacted]")
541            .field("challenge", &self.challenge)
542            .finish()
543    }
544}
545
546/// Generate a PKCE code verifier and S256 challenge per RFC 7636.
547///
548/// The verifier is 43 base64url characters derived from 32 random bytes.
549/// The challenge is `BASE64URL(SHA256(verifier))`.
550pub fn generate_pkce_challenge() -> PkceChallenge {
551    let mut random_bytes = [0u8; 32];
552    rand::rng().fill(&mut random_bytes);
553    let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
554    let verifier = engine.encode(&random_bytes);
555
556    let digest = Sha256::digest(verifier.as_bytes());
557    let challenge = engine.encode(digest);
558
559    PkceChallenge {
560        verifier,
561        challenge,
562    }
563}
564
565// -- Authorization URL construction ------------------------------------------
566
567/// Build the authorization URL for the OAuth Authorization Code + PKCE flow.
568pub fn build_authorization_url(
569    auth_server_metadata: &AuthServerMetadata,
570    client_id: &str,
571    redirect_uri: &str,
572    scopes: &[String],
573    resource: &str,
574    pkce: &PkceChallenge,
575    state: &str,
576) -> Url {
577    let mut url = auth_server_metadata.authorization_endpoint.clone();
578    {
579        let mut query = url.query_pairs_mut();
580        query.append_pair("response_type", "code");
581        query.append_pair("client_id", client_id);
582        query.append_pair("redirect_uri", redirect_uri);
583        if !scopes.is_empty() {
584            query.append_pair("scope", &scopes.join(" "));
585        }
586        query.append_pair("resource", resource);
587        query.append_pair("code_challenge", &pkce.challenge);
588        query.append_pair("code_challenge_method", "S256");
589        query.append_pair("state", state);
590    }
591    url
592}
593
594// -- Token endpoint request bodies -------------------------------------------
595
596/// The JSON body returned by the token endpoint on success.
597#[derive(Deserialize)]
598pub struct TokenResponse {
599    pub access_token: String,
600    #[serde(default)]
601    pub refresh_token: Option<String>,
602    #[serde(default)]
603    pub expires_in: Option<u64>,
604    #[serde(default)]
605    pub token_type: Option<String>,
606}
607
608impl std::fmt::Debug for TokenResponse {
609    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
610        f.debug_struct("TokenResponse")
611            .field("access_token", &"[redacted]")
612            .field(
613                "refresh_token",
614                &self.refresh_token.as_ref().map(|_| "[redacted]"),
615            )
616            .field("expires_in", &self.expires_in)
617            .field("token_type", &self.token_type)
618            .finish()
619    }
620}
621
622impl TokenResponse {
623    /// Convert into `OAuthTokens`, computing `expires_at` from `expires_in`.
624    pub fn into_tokens(self) -> OAuthTokens {
625        let expires_at = self
626            .expires_in
627            .map(|secs| SystemTime::now() + Duration::from_secs(secs));
628        OAuthTokens {
629            access_token: self.access_token,
630            refresh_token: self.refresh_token,
631            expires_at,
632        }
633    }
634}
635
636/// An OAuth token error response (RFC 6749 Section 5.2).
637#[derive(Debug, Deserialize, PartialEq)]
638pub struct OAuthTokenError {
639    pub error: String,
640    #[serde(default)]
641    pub error_description: Option<String>,
642}
643
644impl std::fmt::Display for OAuthTokenError {
645    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
646        write!(f, "OAuth token error: {}", self.error)?;
647        if let Some(description) = &self.error_description {
648            write!(f, " ({description})")?;
649        }
650        Ok(())
651    }
652}
653
654impl std::error::Error for OAuthTokenError {}
655
656/// Build the form-encoded body for an authorization code token exchange.
657pub fn token_exchange_params(
658    code: &str,
659    client_id: &str,
660    redirect_uri: &str,
661    code_verifier: &str,
662    resource: &str,
663    client_secret: Option<&str>,
664) -> Vec<(&'static str, String)> {
665    let mut params = vec![
666        ("grant_type", "authorization_code".to_string()),
667        ("code", code.to_string()),
668        ("redirect_uri", redirect_uri.to_string()),
669        ("client_id", client_id.to_string()),
670        ("code_verifier", code_verifier.to_string()),
671        ("resource", resource.to_string()),
672    ];
673    if let Some(secret) = client_secret {
674        params.push(("client_secret", secret.to_string()));
675    }
676    params
677}
678
679/// Build the form-encoded body for a token refresh request.
680pub fn token_refresh_params(
681    refresh_token: &str,
682    client_id: &str,
683    resource: &str,
684    client_secret: Option<&str>,
685) -> Vec<(&'static str, String)> {
686    let mut params = vec![
687        ("grant_type", "refresh_token".to_string()),
688        ("refresh_token", refresh_token.to_string()),
689        ("client_id", client_id.to_string()),
690        ("resource", resource.to_string()),
691    ];
692    if let Some(secret) = client_secret {
693        params.push(("client_secret", secret.to_string()));
694    }
695    params
696}
697
698// -- DCR request body (RFC 7591) ---------------------------------------------
699
700/// Build the JSON body for a Dynamic Client Registration request.
701///
702/// The `redirect_uri` should be the actual loopback URI with the ephemeral
703/// port (e.g. `http://127.0.0.1:12345/callback`). Some auth servers do strict
704/// redirect URI matching even for loopback addresses, so we register the
705/// exact URI we intend to use.
706/// The grant types Zed can use. Intersected with the server's
707/// `grant_types_supported` to build the DCR request.
708const SUPPORTED_GRANT_TYPES: &[&str] = &["authorization_code", "refresh_token"];
709
710pub fn dcr_registration_body(
711    redirect_uri: &str,
712    server_grant_types: Option<&[String]>,
713) -> serde_json::Value {
714    // Use the intersection of what we support and what the server advertises.
715    // When the server doesn't advertise grant_types_supported, send all of
716    // ours — the server will reject what it doesn't like.
717    let grant_types: Vec<&str> = match server_grant_types {
718        Some(server) => SUPPORTED_GRANT_TYPES
719            .iter()
720            .copied()
721            .filter(|gt| server.iter().any(|s| s == *gt))
722            .collect(),
723        None => SUPPORTED_GRANT_TYPES.to_vec(),
724    };
725
726    serde_json::json!({
727        "client_name": "Zed",
728        "redirect_uris": [redirect_uri],
729        "grant_types": grant_types,
730        "response_types": ["code"],
731        "token_endpoint_auth_method": "none"
732    })
733}
734
735// -- Discovery (async, hits real endpoints) ----------------------------------
736
737/// Fetch Protected Resource Metadata from the MCP server.
738///
739/// Tries the `resource_metadata` URL from the `WWW-Authenticate` header first,
740/// then falls back to well-known URIs constructed from `server_url`.
741pub async fn fetch_protected_resource_metadata(
742    http_client: &Arc<dyn HttpClient>,
743    server_url: &Url,
744    www_authenticate: &WwwAuthenticate,
745) -> Result<ProtectedResourceMetadata> {
746    let candidate_urls = match &www_authenticate.resource_metadata {
747        Some(url) if url.origin() == server_url.origin() => {
748            // Try the header-provided URL first (per MCP spec: "use the resource
749            // metadata URL from the parsed WWW-Authenticate headers when present"),
750            // then fall back to RFC 9728 well-known URIs in case the header URL is
751            // wrong (e.g. a buggy server that doubles the path component).
752            let mut urls = vec![url.clone()];
753            for fallback in protected_resource_metadata_urls(server_url) {
754                if !urls.contains(&fallback) {
755                    urls.push(fallback);
756                }
757            }
758            urls
759        }
760        Some(url) => {
761            log::warn!(
762                "Ignoring cross-origin resource_metadata URL {} \
763                 (server origin: {})",
764                url,
765                server_url.origin().unicode_serialization()
766            );
767            protected_resource_metadata_urls(server_url)
768        }
769        None => protected_resource_metadata_urls(server_url),
770    };
771
772    for url in &candidate_urls {
773        match fetch_json::<ProtectedResourceMetadataResponse>(http_client, url).await {
774            Ok(response) => {
775                if response.authorization_servers.is_empty() {
776                    bail!(
777                        "Protected Resource Metadata at {} has no authorization_servers",
778                        url
779                    );
780                }
781                return Ok(ProtectedResourceMetadata {
782                    resource: response.resource.unwrap_or_else(|| server_url.clone()),
783                    authorization_servers: response.authorization_servers,
784                    scopes_supported: response.scopes_supported,
785                });
786            }
787            Err(err) => {
788                log::debug!(
789                    "Failed to fetch Protected Resource Metadata from {}: {}",
790                    url,
791                    err
792                );
793            }
794        }
795    }
796
797    bail!(
798        "Could not fetch Protected Resource Metadata for {}",
799        server_url
800    )
801}
802
803/// Fetch Authorization Server Metadata, trying RFC 8414 and OIDC Discovery
804/// endpoints in the priority order specified by the MCP spec.
805pub async fn fetch_auth_server_metadata(
806    http_client: &Arc<dyn HttpClient>,
807    issuer: &Url,
808) -> Result<AuthServerMetadata> {
809    let candidate_urls = auth_server_metadata_urls(issuer);
810
811    for url in &candidate_urls {
812        match fetch_json::<AuthServerMetadataResponse>(http_client, url).await {
813            Ok(response) => {
814                let reported_issuer = response.issuer.unwrap_or_else(|| issuer.clone());
815
816                if reported_issuer != *issuer {
817                    bail!(
818                        "Auth server metadata issuer mismatch: expected {}, got {}",
819                        issuer,
820                        reported_issuer
821                    );
822                }
823
824                return Ok(AuthServerMetadata {
825                    issuer: reported_issuer,
826                    grant_types_supported: response.grant_types_supported,
827                    authorization_endpoint: response
828                        .authorization_endpoint
829                        .ok_or_else(|| anyhow!("missing authorization_endpoint"))?,
830                    token_endpoint: response
831                        .token_endpoint
832                        .ok_or_else(|| anyhow!("missing token_endpoint"))?,
833                    registration_endpoint: response.registration_endpoint,
834                    scopes_supported: response.scopes_supported,
835                    code_challenge_methods_supported: response.code_challenge_methods_supported,
836                    client_id_metadata_document_supported: response
837                        .client_id_metadata_document_supported
838                        .unwrap_or(false),
839                });
840            }
841            Err(err) => {
842                log::debug!("Failed to fetch Auth Server Metadata from {}: {}", url, err);
843            }
844        }
845    }
846
847    bail!(
848        "Could not fetch Authorization Server Metadata for {}",
849        issuer
850    )
851}
852
853/// Run the full discovery flow: fetch resource metadata, then auth server
854/// metadata, then select scopes. Client registration is resolved separately,
855/// once the real redirect URI is known.
856pub async fn discover(
857    http_client: &Arc<dyn HttpClient>,
858    server_url: &Url,
859    www_authenticate: &WwwAuthenticate,
860) -> Result<OAuthDiscovery> {
861    let resource_metadata =
862        fetch_protected_resource_metadata(http_client, server_url, www_authenticate).await?;
863
864    let auth_server_url = resource_metadata
865        .authorization_servers
866        .first()
867        .ok_or_else(|| anyhow!("no authorization servers in resource metadata"))?;
868
869    let auth_server_metadata = fetch_auth_server_metadata(http_client, auth_server_url).await?;
870
871    // Verify PKCE S256 support (spec requirement).
872    match &auth_server_metadata.code_challenge_methods_supported {
873        Some(methods) if methods.iter().any(|m| m == "S256") => {}
874        Some(_) => bail!("authorization server does not support S256 PKCE"),
875        None => bail!("authorization server does not advertise code_challenge_methods_supported"),
876    }
877
878    let scopes = select_scopes(www_authenticate, &resource_metadata);
879
880    Ok(OAuthDiscovery {
881        resource_metadata,
882        auth_server_metadata,
883        scopes,
884    })
885}
886
887/// Resolve the OAuth client registration for an authorization flow.
888///
889/// CIMD uses the static client metadata document directly. For DCR, a fresh
890/// registration is performed each time because the loopback redirect URI
891/// includes an ephemeral port that changes every flow.
892pub async fn resolve_client_registration(
893    http_client: &Arc<dyn HttpClient>,
894    discovery: &OAuthDiscovery,
895    redirect_uri: &str,
896) -> Result<OAuthClientRegistration> {
897    match determine_registration_strategy(&discovery.auth_server_metadata) {
898        ClientRegistrationStrategy::Cimd { client_id } => Ok(OAuthClientRegistration {
899            client_id,
900            client_secret: None,
901        }),
902        ClientRegistrationStrategy::Dcr {
903            registration_endpoint,
904        } => {
905            perform_dcr(
906                http_client,
907                &registration_endpoint,
908                redirect_uri,
909                discovery
910                    .auth_server_metadata
911                    .grant_types_supported
912                    .as_deref(),
913            )
914            .await
915        }
916        ClientRegistrationStrategy::Unavailable => {
917            bail!("authorization server supports neither CIMD nor DCR")
918        }
919    }
920}
921
922// -- Dynamic Client Registration (RFC 7591) ----------------------------------
923
924/// Perform Dynamic Client Registration with the authorization server.
925pub async fn perform_dcr(
926    http_client: &Arc<dyn HttpClient>,
927    registration_endpoint: &Url,
928    redirect_uri: &str,
929    server_grant_types: Option<&[String]>,
930) -> Result<OAuthClientRegistration> {
931    validate_oauth_url(registration_endpoint)?;
932
933    let body = dcr_registration_body(redirect_uri, server_grant_types);
934    let body_bytes = serde_json::to_vec(&body)?;
935
936    let request = Request::builder()
937        .method(http_client::http::Method::POST)
938        .uri(registration_endpoint.as_str())
939        .header("Content-Type", "application/json")
940        .header("Accept", "application/json")
941        .body(AsyncBody::from(body_bytes))?;
942
943    let mut response = http_client.send(request).await?;
944
945    if !response.status().is_success() {
946        let mut error_body = String::new();
947        response.body_mut().read_to_string(&mut error_body).await?;
948        bail!(
949            "DCR failed with status {}: {}",
950            response.status(),
951            error_body
952        );
953    }
954
955    let mut response_body = String::new();
956    response
957        .body_mut()
958        .read_to_string(&mut response_body)
959        .await?;
960
961    let dcr_response: DcrResponse =
962        serde_json::from_str(&response_body).context("failed to parse DCR response")?;
963
964    Ok(OAuthClientRegistration {
965        client_id: dcr_response.client_id,
966        client_secret: dcr_response.client_secret,
967    })
968}
969
970// -- Token exchange and refresh (async) --------------------------------------
971
972/// Exchange an authorization code for tokens at the token endpoint.
973pub async fn exchange_code(
974    http_client: &Arc<dyn HttpClient>,
975    auth_server_metadata: &AuthServerMetadata,
976    code: &str,
977    client_id: &str,
978    redirect_uri: &str,
979    code_verifier: &str,
980    resource: &str,
981    client_secret: Option<&str>,
982) -> Result<OAuthTokens> {
983    let params = token_exchange_params(
984        code,
985        client_id,
986        redirect_uri,
987        code_verifier,
988        resource,
989        client_secret,
990    );
991    post_token_request(http_client, &auth_server_metadata.token_endpoint, &params).await
992}
993
994/// Refresh tokens using a refresh token.
995pub async fn refresh_tokens(
996    http_client: &Arc<dyn HttpClient>,
997    token_endpoint: &Url,
998    refresh_token: &str,
999    client_id: &str,
1000    resource: &str,
1001    client_secret: Option<&str>,
1002) -> Result<OAuthTokens> {
1003    let params = token_refresh_params(refresh_token, client_id, resource, client_secret);
1004    post_token_request(http_client, token_endpoint, &params).await
1005}
1006
1007/// POST form-encoded parameters to a token endpoint and parse the response.
1008async fn post_token_request(
1009    http_client: &Arc<dyn HttpClient>,
1010    token_endpoint: &Url,
1011    params: &[(&str, String)],
1012) -> Result<OAuthTokens> {
1013    validate_oauth_url(token_endpoint)?;
1014
1015    let body = url::form_urlencoded::Serializer::new(String::new())
1016        .extend_pairs(params.iter().map(|(k, v)| (*k, v.as_str())))
1017        .finish();
1018
1019    let request = Request::builder()
1020        .method(http_client::http::Method::POST)
1021        .uri(token_endpoint.as_str())
1022        .header("Content-Type", "application/x-www-form-urlencoded")
1023        .header("Accept", "application/json")
1024        .body(AsyncBody::from(body.into_bytes()))?;
1025
1026    let mut response = http_client.send(request).await?;
1027
1028    if !response.status().is_success() {
1029        let mut error_body = String::new();
1030        response.body_mut().read_to_string(&mut error_body).await?;
1031        let status = response.status();
1032        // Try to parse as an OAuth error response (RFC 6749 Section 5.2).
1033        if let Ok(token_error) = serde_json::from_str::<OAuthTokenError>(&error_body) {
1034            return Err(token_error.into());
1035        }
1036        bail!("token request failed with status {status}: {error_body}");
1037    }
1038
1039    let mut response_body = String::new();
1040    response
1041        .body_mut()
1042        .read_to_string(&mut response_body)
1043        .await?;
1044
1045    let token_response: TokenResponse =
1046        serde_json::from_str(&response_body).context("failed to parse token response")?;
1047
1048    Ok(token_response.into_tokens())
1049}
1050
1051// -- Loopback HTTP callback server -------------------------------------------
1052
1053/// An OAuth authorization callback received via the loopback HTTP server.
1054pub struct OAuthCallback {
1055    pub code: String,
1056    pub state: String,
1057}
1058
1059impl std::fmt::Debug for OAuthCallback {
1060    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1061        f.debug_struct("OAuthCallback")
1062            .field("code", &"[redacted]")
1063            .field("state", &"[redacted]")
1064            .finish()
1065    }
1066}
1067
1068impl OAuthCallback {
1069    /// Parse the query string from a callback URL like
1070    /// `http://127.0.0.1:<port>/callback?code=...&state=...`.
1071    pub fn parse_query(query: &str) -> Result<Self> {
1072        let params = oauth_callback_server::OAuthCallbackParams::parse_query(query)?;
1073        Ok(Self {
1074            code: params.code,
1075            state: params.state,
1076        })
1077    }
1078}
1079
1080/// Start a loopback HTTP server to receive the OAuth authorization callback.
1081///
1082/// Binds to an ephemeral loopback port for each flow.
1083///
1084/// Returns `(redirect_uri, callback_future)`. The caller should use the
1085/// redirect URI in the authorization request, open the browser, then await
1086/// the future to receive the callback.
1087///
1088/// The server accepts exactly one request on `/callback`, validates that it
1089/// contains `code` and `state` query parameters, responds with a minimal
1090/// HTML page telling the user they can close the tab, and shuts down.
1091///
1092/// The callback server shuts down when the returned future is dropped (e.g.
1093/// because the authentication task was cancelled), or after a timeout.
1094pub fn start_callback_server() -> Result<(String, BoxFuture<'static, Result<OAuthCallback>>)> {
1095    let (redirect_uri, rx) = oauth_callback_server::start_oauth_callback_server()?;
1096    let future = async move {
1097        match rx.await {
1098            Ok(Ok(params)) => Ok(OAuthCallback {
1099                code: params.code,
1100                state: params.state,
1101            }),
1102            Ok(Err(e)) => Err(e),
1103            Err(_) => Err(anyhow!(
1104                "OAuth callback server was shut down before receiving a response"
1105            )),
1106        }
1107    }
1108    .boxed();
1109    Ok((redirect_uri, future))
1110}
1111
1112// -- JSON fetch helper -------------------------------------------------------
1113
1114async fn fetch_json<T: serde::de::DeserializeOwned>(
1115    http_client: &Arc<dyn HttpClient>,
1116    url: &Url,
1117) -> Result<T> {
1118    validate_oauth_url(url)?;
1119
1120    let request = Request::builder()
1121        .method(http_client::http::Method::GET)
1122        .uri(url.as_str())
1123        .header("Accept", "application/json")
1124        .body(AsyncBody::default())?;
1125
1126    let mut response = http_client.send(request).await?;
1127
1128    if !response.status().is_success() {
1129        bail!("HTTP {} fetching {}", response.status(), url);
1130    }
1131
1132    let mut body = String::new();
1133    response.body_mut().read_to_string(&mut body).await?;
1134    serde_json::from_str(&body).with_context(|| format!("failed to parse JSON from {}", url))
1135}
1136
1137// -- Serde response types for discovery --------------------------------------
1138
1139#[derive(Debug, Deserialize)]
1140struct ProtectedResourceMetadataResponse {
1141    #[serde(default)]
1142    resource: Option<Url>,
1143    #[serde(default)]
1144    authorization_servers: Vec<Url>,
1145    #[serde(default)]
1146    scopes_supported: Option<Vec<String>>,
1147}
1148
1149#[derive(Debug, Deserialize)]
1150struct AuthServerMetadataResponse {
1151    #[serde(default)]
1152    issuer: Option<Url>,
1153    #[serde(default)]
1154    authorization_endpoint: Option<Url>,
1155    #[serde(default)]
1156    token_endpoint: Option<Url>,
1157    #[serde(default)]
1158    registration_endpoint: Option<Url>,
1159    #[serde(default)]
1160    scopes_supported: Option<Vec<String>>,
1161    #[serde(default)]
1162    grant_types_supported: Option<Vec<String>>,
1163    #[serde(default)]
1164    code_challenge_methods_supported: Option<Vec<String>>,
1165    #[serde(default)]
1166    client_id_metadata_document_supported: Option<bool>,
1167}
1168
1169#[derive(Debug, Deserialize)]
1170struct DcrResponse {
1171    client_id: String,
1172    #[serde(default)]
1173    client_secret: Option<String>,
1174}
1175
1176/// Provides OAuth tokens to the HTTP transport layer.
1177///
1178/// The transport calls `access_token()` before each request. On a 401 response
1179/// it calls `try_refresh()` and retries once if the refresh succeeds.
1180#[async_trait]
1181pub trait OAuthTokenProvider: Send + Sync {
1182    /// Returns the current access token, if one is available.
1183    fn access_token(&self) -> Option<String>;
1184
1185    /// Attempts to refresh the access token. Returns `true` if a new token was
1186    /// obtained and the request should be retried.
1187    async fn try_refresh(&self) -> Result<bool>;
1188}
1189
1190/// Concrete `OAuthTokenProvider` backed by a full persisted OAuth session and
1191/// an HTTP client for token refresh. The same provider type is used both after
1192/// an interactive authentication flow and when restoring a saved session from
1193/// the keychain on startup.
1194pub struct McpOAuthTokenProvider {
1195    session: SyncMutex<OAuthSession>,
1196    http_client: Arc<dyn HttpClient>,
1197    token_refresh_tx: Option<mpsc::UnboundedSender<OAuthSession>>,
1198}
1199
1200impl McpOAuthTokenProvider {
1201    pub fn new(
1202        session: OAuthSession,
1203        http_client: Arc<dyn HttpClient>,
1204        token_refresh_tx: Option<mpsc::UnboundedSender<OAuthSession>>,
1205    ) -> Self {
1206        Self {
1207            session: SyncMutex::new(session),
1208            http_client,
1209            token_refresh_tx,
1210        }
1211    }
1212
1213    fn access_token_is_expired(tokens: &OAuthTokens) -> bool {
1214        tokens.expires_at.is_some_and(|expires_at| {
1215            SystemTime::now()
1216                .checked_add(Duration::from_secs(30))
1217                .is_some_and(|now_with_buffer| expires_at <= now_with_buffer)
1218        })
1219    }
1220}
1221
1222#[async_trait]
1223impl OAuthTokenProvider for McpOAuthTokenProvider {
1224    fn access_token(&self) -> Option<String> {
1225        let session = self.session.lock();
1226        if Self::access_token_is_expired(&session.tokens) {
1227            return None;
1228        }
1229        Some(session.tokens.access_token.clone())
1230    }
1231
1232    async fn try_refresh(&self) -> Result<bool> {
1233        let (refresh_token, token_endpoint, resource, client_id, client_secret) = {
1234            let session = self.session.lock();
1235            match session.tokens.refresh_token.clone() {
1236                Some(refresh_token) => (
1237                    refresh_token,
1238                    session.token_endpoint.clone(),
1239                    session.resource.clone(),
1240                    session.client_registration.client_id.clone(),
1241                    session.client_registration.client_secret.clone(),
1242                ),
1243                None => return Ok(false),
1244            }
1245        };
1246
1247        let resource_str = canonical_server_uri(&resource);
1248
1249        match refresh_tokens(
1250            &self.http_client,
1251            &token_endpoint,
1252            &refresh_token,
1253            &client_id,
1254            &resource_str,
1255            client_secret.as_deref(),
1256        )
1257        .await
1258        {
1259            Ok(mut new_tokens) => {
1260                if new_tokens.refresh_token.is_none() {
1261                    new_tokens.refresh_token = Some(refresh_token);
1262                }
1263
1264                {
1265                    let mut session = self.session.lock();
1266                    session.tokens = new_tokens;
1267
1268                    if let Some(ref tx) = self.token_refresh_tx {
1269                        tx.unbounded_send(session.clone()).ok();
1270                    }
1271                }
1272
1273                Ok(true)
1274            }
1275            Err(err) => {
1276                log::warn!("OAuth token refresh failed: {}", err);
1277                Ok(false)
1278            }
1279        }
1280    }
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285    use super::*;
1286    use http_client::Response;
1287
1288    // -- require_https_or_loopback tests ------------------------------------
1289
1290    #[test]
1291    fn test_require_https_or_loopback_accepts_https() {
1292        let url = Url::parse("https://auth.example.com/token").unwrap();
1293        assert!(require_https_or_loopback(&url).is_ok());
1294    }
1295
1296    #[test]
1297    fn test_require_https_or_loopback_rejects_http_remote() {
1298        let url = Url::parse("http://auth.example.com/token").unwrap();
1299        assert!(require_https_or_loopback(&url).is_err());
1300    }
1301
1302    #[test]
1303    fn test_require_https_or_loopback_accepts_http_127_0_0_1() {
1304        let url = Url::parse("http://127.0.0.1:8080/callback").unwrap();
1305        assert!(require_https_or_loopback(&url).is_ok());
1306    }
1307
1308    #[test]
1309    fn test_require_https_or_loopback_accepts_http_ipv6_loopback() {
1310        let url = Url::parse("http://[::1]:8080/callback").unwrap();
1311        assert!(require_https_or_loopback(&url).is_ok());
1312    }
1313
1314    #[test]
1315    fn test_require_https_or_loopback_accepts_http_localhost() {
1316        let url = Url::parse("http://localhost:8080/callback").unwrap();
1317        assert!(require_https_or_loopback(&url).is_ok());
1318    }
1319
1320    #[test]
1321    fn test_require_https_or_loopback_accepts_http_localhost_case_insensitive() {
1322        let url = Url::parse("http://LOCALHOST:8080/callback").unwrap();
1323        assert!(require_https_or_loopback(&url).is_ok());
1324    }
1325
1326    #[test]
1327    fn test_require_https_or_loopback_rejects_http_non_loopback_ip() {
1328        let url = Url::parse("http://192.168.1.1:8080/token").unwrap();
1329        assert!(require_https_or_loopback(&url).is_err());
1330    }
1331
1332    #[test]
1333    fn test_require_https_or_loopback_rejects_ftp() {
1334        let url = Url::parse("ftp://auth.example.com/token").unwrap();
1335        assert!(require_https_or_loopback(&url).is_err());
1336    }
1337
1338    // -- validate_oauth_url (SSRF) tests ------------------------------------
1339
1340    #[test]
1341    fn test_validate_oauth_url_accepts_https_public() {
1342        let url = Url::parse("https://auth.example.com/token").unwrap();
1343        assert!(validate_oauth_url(&url).is_ok());
1344    }
1345
1346    #[test]
1347    fn test_validate_oauth_url_rejects_private_ipv4_10() {
1348        let url = Url::parse("https://10.0.0.1/token").unwrap();
1349        assert!(validate_oauth_url(&url).is_err());
1350    }
1351
1352    #[test]
1353    fn test_validate_oauth_url_rejects_private_ipv4_172() {
1354        let url = Url::parse("https://172.16.0.1/token").unwrap();
1355        assert!(validate_oauth_url(&url).is_err());
1356    }
1357
1358    #[test]
1359    fn test_validate_oauth_url_rejects_private_ipv4_192() {
1360        let url = Url::parse("https://192.168.1.1/token").unwrap();
1361        assert!(validate_oauth_url(&url).is_err());
1362    }
1363
1364    #[test]
1365    fn test_validate_oauth_url_rejects_link_local() {
1366        let url = Url::parse("https://169.254.169.254/latest/meta-data/").unwrap();
1367        assert!(validate_oauth_url(&url).is_err());
1368    }
1369
1370    #[test]
1371    fn test_validate_oauth_url_rejects_ipv6_ula() {
1372        let url = Url::parse("https://[fd12:3456:789a::1]/token").unwrap();
1373        assert!(validate_oauth_url(&url).is_err());
1374    }
1375
1376    #[test]
1377    fn test_validate_oauth_url_rejects_ipv6_unspecified() {
1378        let url = Url::parse("https://[::]/token").unwrap();
1379        assert!(validate_oauth_url(&url).is_err());
1380    }
1381
1382    #[test]
1383    fn test_validate_oauth_url_rejects_ipv4_mapped_ipv6_private() {
1384        let url = Url::parse("https://[::ffff:10.0.0.1]/token").unwrap();
1385        assert!(validate_oauth_url(&url).is_err());
1386    }
1387
1388    #[test]
1389    fn test_validate_oauth_url_rejects_ipv4_mapped_ipv6_link_local() {
1390        let url = Url::parse("https://[::ffff:169.254.169.254]/token").unwrap();
1391        assert!(validate_oauth_url(&url).is_err());
1392    }
1393
1394    #[test]
1395    fn test_validate_oauth_url_allows_http_loopback() {
1396        // Loopback is permitted (it's our callback server).
1397        let url = Url::parse("http://127.0.0.1:8080/callback").unwrap();
1398        assert!(validate_oauth_url(&url).is_ok());
1399    }
1400
1401    #[test]
1402    fn test_validate_oauth_url_allows_https_public_ip() {
1403        let url = Url::parse("https://93.184.216.34/token").unwrap();
1404        assert!(validate_oauth_url(&url).is_ok());
1405    }
1406
1407    // -- parse_www_authenticate tests ----------------------------------------
1408
1409    #[test]
1410    fn test_parse_www_authenticate_with_resource_metadata_and_scope() {
1411        let header = r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource", scope="files:read user:profile""#;
1412        let result = parse_www_authenticate(header).unwrap();
1413
1414        assert_eq!(
1415            result.resource_metadata.as_ref().map(|u| u.as_str()),
1416            Some("https://mcp.example.com/.well-known/oauth-protected-resource")
1417        );
1418        assert_eq!(
1419            result.scope,
1420            Some(vec!["files:read".to_string(), "user:profile".to_string()])
1421        );
1422        assert_eq!(result.error, None);
1423    }
1424
1425    #[test]
1426    fn test_parse_www_authenticate_resource_metadata_only() {
1427        let header = r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#;
1428        let result = parse_www_authenticate(header).unwrap();
1429
1430        assert_eq!(
1431            result.resource_metadata.as_ref().map(|u| u.as_str()),
1432            Some("https://mcp.example.com/.well-known/oauth-protected-resource")
1433        );
1434        assert_eq!(result.scope, None);
1435    }
1436
1437    #[test]
1438    fn test_parse_www_authenticate_bare_bearer() {
1439        let result = parse_www_authenticate("Bearer").unwrap();
1440        assert_eq!(result.resource_metadata, None);
1441        assert_eq!(result.scope, None);
1442    }
1443
1444    #[test]
1445    fn test_parse_www_authenticate_with_error() {
1446        let header = r#"Bearer error="insufficient_scope", scope="files:read files:write", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource", error_description="Additional file write permission required""#;
1447        let result = parse_www_authenticate(header).unwrap();
1448
1449        assert_eq!(result.error, Some(BearerError::InsufficientScope));
1450        assert_eq!(
1451            result.error_description.as_deref(),
1452            Some("Additional file write permission required")
1453        );
1454        assert_eq!(
1455            result.scope,
1456            Some(vec!["files:read".to_string(), "files:write".to_string()])
1457        );
1458        assert!(result.resource_metadata.is_some());
1459    }
1460
1461    #[test]
1462    fn test_parse_www_authenticate_invalid_token_error() {
1463        let header =
1464            r#"Bearer error="invalid_token", error_description="The access token expired""#;
1465        let result = parse_www_authenticate(header).unwrap();
1466        assert_eq!(result.error, Some(BearerError::InvalidToken));
1467    }
1468
1469    #[test]
1470    fn test_parse_www_authenticate_invalid_request_error() {
1471        let header = r#"Bearer error="invalid_request""#;
1472        let result = parse_www_authenticate(header).unwrap();
1473        assert_eq!(result.error, Some(BearerError::InvalidRequest));
1474    }
1475
1476    #[test]
1477    fn test_parse_www_authenticate_unknown_error() {
1478        let header = r#"Bearer error="some_future_error""#;
1479        let result = parse_www_authenticate(header).unwrap();
1480        assert_eq!(result.error, Some(BearerError::Other));
1481    }
1482
1483    #[test]
1484    fn test_parse_www_authenticate_rejects_non_bearer() {
1485        let result = parse_www_authenticate("Basic realm=\"example\"");
1486        assert!(result.is_err());
1487    }
1488
1489    #[test]
1490    fn test_parse_www_authenticate_case_insensitive_scheme() {
1491        let header = r#"bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource""#;
1492        let result = parse_www_authenticate(header).unwrap();
1493        assert!(result.resource_metadata.is_some());
1494    }
1495
1496    #[test]
1497    fn test_parse_www_authenticate_multiline_style() {
1498        // Some servers emit the header spread across multiple lines joined by
1499        // whitespace, as shown in the spec examples.
1500        let header = "Bearer resource_metadata=\"https://mcp.example.com/.well-known/oauth-protected-resource\",\n                         scope=\"files:read\"";
1501        let result = parse_www_authenticate(header).unwrap();
1502        assert!(result.resource_metadata.is_some());
1503        assert_eq!(result.scope, Some(vec!["files:read".to_string()]));
1504    }
1505
1506    #[test]
1507    fn test_protected_resource_metadata_urls_with_path() {
1508        let server_url = Url::parse("https://api.example.com/v1/mcp").unwrap();
1509        let urls = protected_resource_metadata_urls(&server_url);
1510
1511        assert_eq!(urls.len(), 2);
1512        assert_eq!(
1513            urls[0].as_str(),
1514            "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp"
1515        );
1516        assert_eq!(
1517            urls[1].as_str(),
1518            "https://api.example.com/.well-known/oauth-protected-resource"
1519        );
1520    }
1521
1522    #[test]
1523    fn test_protected_resource_metadata_urls_without_path() {
1524        let server_url = Url::parse("https://mcp.example.com").unwrap();
1525        let urls = protected_resource_metadata_urls(&server_url);
1526
1527        assert_eq!(urls.len(), 1);
1528        assert_eq!(
1529            urls[0].as_str(),
1530            "https://mcp.example.com/.well-known/oauth-protected-resource"
1531        );
1532    }
1533
1534    #[test]
1535    fn test_auth_server_metadata_urls_with_path() {
1536        let issuer = Url::parse("https://auth.example.com/tenant1").unwrap();
1537        let urls = auth_server_metadata_urls(&issuer);
1538
1539        assert_eq!(urls.len(), 3);
1540        assert_eq!(
1541            urls[0].as_str(),
1542            "https://auth.example.com/.well-known/oauth-authorization-server/tenant1"
1543        );
1544        assert_eq!(
1545            urls[1].as_str(),
1546            "https://auth.example.com/.well-known/openid-configuration/tenant1"
1547        );
1548        assert_eq!(
1549            urls[2].as_str(),
1550            "https://auth.example.com/tenant1/.well-known/openid-configuration"
1551        );
1552    }
1553
1554    #[test]
1555    fn test_auth_server_metadata_urls_without_path() {
1556        let issuer = Url::parse("https://auth.example.com").unwrap();
1557        let urls = auth_server_metadata_urls(&issuer);
1558
1559        assert_eq!(urls.len(), 2);
1560        assert_eq!(
1561            urls[0].as_str(),
1562            "https://auth.example.com/.well-known/oauth-authorization-server"
1563        );
1564        assert_eq!(
1565            urls[1].as_str(),
1566            "https://auth.example.com/.well-known/openid-configuration"
1567        );
1568    }
1569
1570    // -- Canonical server URI tests ------------------------------------------
1571
1572    #[test]
1573    fn test_canonical_server_uri_simple() {
1574        let url = Url::parse("https://mcp.example.com").unwrap();
1575        assert_eq!(canonical_server_uri(&url), "https://mcp.example.com");
1576    }
1577
1578    #[test]
1579    fn test_canonical_server_uri_with_path() {
1580        let url = Url::parse("https://mcp.example.com/v1/mcp").unwrap();
1581        assert_eq!(canonical_server_uri(&url), "https://mcp.example.com/v1/mcp");
1582    }
1583
1584    #[test]
1585    fn test_canonical_server_uri_strips_trailing_slash() {
1586        let url = Url::parse("https://mcp.example.com/").unwrap();
1587        assert_eq!(canonical_server_uri(&url), "https://mcp.example.com");
1588    }
1589
1590    #[test]
1591    fn test_canonical_server_uri_preserves_port() {
1592        let url = Url::parse("https://mcp.example.com:8443").unwrap();
1593        assert_eq!(canonical_server_uri(&url), "https://mcp.example.com:8443");
1594    }
1595
1596    #[test]
1597    fn test_canonical_server_uri_lowercases() {
1598        let url = Url::parse("HTTPS://MCP.Example.COM/Server/MCP").unwrap();
1599        assert_eq!(
1600            canonical_server_uri(&url),
1601            "https://mcp.example.com/Server/MCP"
1602        );
1603    }
1604
1605    // -- Scope selection tests -----------------------------------------------
1606
1607    #[test]
1608    fn test_select_scopes_prefers_www_authenticate() {
1609        let www_auth = WwwAuthenticate {
1610            resource_metadata: None,
1611            scope: Some(vec!["files:read".into()]),
1612            error: None,
1613            error_description: None,
1614        };
1615        let resource_meta = ProtectedResourceMetadata {
1616            resource: Url::parse("https://example.com").unwrap(),
1617            authorization_servers: vec![],
1618            scopes_supported: Some(vec!["files:read".into(), "files:write".into()]),
1619        };
1620        assert_eq!(select_scopes(&www_auth, &resource_meta), vec!["files:read"]);
1621    }
1622
1623    #[test]
1624    fn test_select_scopes_falls_back_to_resource_metadata() {
1625        let www_auth = WwwAuthenticate {
1626            resource_metadata: None,
1627            scope: None,
1628            error: None,
1629            error_description: None,
1630        };
1631        let resource_meta = ProtectedResourceMetadata {
1632            resource: Url::parse("https://example.com").unwrap(),
1633            authorization_servers: vec![],
1634            scopes_supported: Some(vec!["admin".into()]),
1635        };
1636        assert_eq!(select_scopes(&www_auth, &resource_meta), vec!["admin"]);
1637    }
1638
1639    #[test]
1640    fn test_select_scopes_empty_when_nothing_available() {
1641        let www_auth = WwwAuthenticate {
1642            resource_metadata: None,
1643            scope: None,
1644            error: None,
1645            error_description: None,
1646        };
1647        let resource_meta = ProtectedResourceMetadata {
1648            resource: Url::parse("https://example.com").unwrap(),
1649            authorization_servers: vec![],
1650            scopes_supported: None,
1651        };
1652        assert!(select_scopes(&www_auth, &resource_meta).is_empty());
1653    }
1654
1655    // -- Client registration strategy tests ----------------------------------
1656
1657    #[test]
1658    fn test_registration_strategy_prefers_cimd() {
1659        let metadata = AuthServerMetadata {
1660            issuer: Url::parse("https://auth.example.com").unwrap(),
1661            authorization_endpoint: Url::parse("https://auth.example.com/authorize").unwrap(),
1662            token_endpoint: Url::parse("https://auth.example.com/token").unwrap(),
1663            registration_endpoint: Some(Url::parse("https://auth.example.com/register").unwrap()),
1664            scopes_supported: None,
1665            code_challenge_methods_supported: Some(vec!["S256".into()]),
1666            client_id_metadata_document_supported: true,
1667            grant_types_supported: None,
1668        };
1669        assert_eq!(
1670            determine_registration_strategy(&metadata),
1671            ClientRegistrationStrategy::Cimd {
1672                client_id: CIMD_URL.to_string(),
1673            }
1674        );
1675    }
1676
1677    #[test]
1678    fn test_registration_strategy_falls_back_to_dcr() {
1679        let reg_endpoint = Url::parse("https://auth.example.com/register").unwrap();
1680        let metadata = AuthServerMetadata {
1681            issuer: Url::parse("https://auth.example.com").unwrap(),
1682            authorization_endpoint: Url::parse("https://auth.example.com/authorize").unwrap(),
1683            token_endpoint: Url::parse("https://auth.example.com/token").unwrap(),
1684            registration_endpoint: Some(reg_endpoint.clone()),
1685            scopes_supported: None,
1686            code_challenge_methods_supported: Some(vec!["S256".into()]),
1687            client_id_metadata_document_supported: false,
1688            grant_types_supported: None,
1689        };
1690        assert_eq!(
1691            determine_registration_strategy(&metadata),
1692            ClientRegistrationStrategy::Dcr {
1693                registration_endpoint: reg_endpoint,
1694            }
1695        );
1696    }
1697
1698    #[test]
1699    fn test_registration_strategy_unavailable() {
1700        let metadata = AuthServerMetadata {
1701            issuer: Url::parse("https://auth.example.com").unwrap(),
1702            authorization_endpoint: Url::parse("https://auth.example.com/authorize").unwrap(),
1703            token_endpoint: Url::parse("https://auth.example.com/token").unwrap(),
1704            registration_endpoint: None,
1705            scopes_supported: None,
1706            code_challenge_methods_supported: Some(vec!["S256".into()]),
1707            client_id_metadata_document_supported: false,
1708            grant_types_supported: None,
1709        };
1710        assert_eq!(
1711            determine_registration_strategy(&metadata),
1712            ClientRegistrationStrategy::Unavailable,
1713        );
1714    }
1715
1716    // -- PKCE tests ----------------------------------------------------------
1717
1718    #[test]
1719    fn test_pkce_challenge_verifier_length() {
1720        let pkce = generate_pkce_challenge();
1721        // 32 random bytes → 43 base64url chars (no padding).
1722        assert_eq!(pkce.verifier.len(), 43);
1723    }
1724
1725    #[test]
1726    fn test_pkce_challenge_is_valid_base64url() {
1727        let pkce = generate_pkce_challenge();
1728        for c in pkce.verifier.chars().chain(pkce.challenge.chars()) {
1729            assert!(
1730                c.is_ascii_alphanumeric() || c == '-' || c == '_',
1731                "invalid base64url character: {}",
1732                c
1733            );
1734        }
1735    }
1736
1737    #[test]
1738    fn test_pkce_challenge_is_s256_of_verifier() {
1739        let pkce = generate_pkce_challenge();
1740        let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
1741        let expected_digest = Sha256::digest(pkce.verifier.as_bytes());
1742        let expected_challenge = engine.encode(expected_digest);
1743        assert_eq!(pkce.challenge, expected_challenge);
1744    }
1745
1746    #[test]
1747    fn test_pkce_challenges_are_unique() {
1748        let a = generate_pkce_challenge();
1749        let b = generate_pkce_challenge();
1750        assert_ne!(a.verifier, b.verifier);
1751    }
1752
1753    // -- Authorization URL tests ---------------------------------------------
1754
1755    #[test]
1756    fn test_build_authorization_url() {
1757        let metadata = AuthServerMetadata {
1758            issuer: Url::parse("https://auth.example.com").unwrap(),
1759            authorization_endpoint: Url::parse("https://auth.example.com/authorize").unwrap(),
1760            token_endpoint: Url::parse("https://auth.example.com/token").unwrap(),
1761            registration_endpoint: None,
1762            scopes_supported: None,
1763            code_challenge_methods_supported: Some(vec!["S256".into()]),
1764            client_id_metadata_document_supported: true,
1765            grant_types_supported: None,
1766        };
1767        let pkce = PkceChallenge {
1768            verifier: "test_verifier".into(),
1769            challenge: "test_challenge".into(),
1770        };
1771        let url = build_authorization_url(
1772            &metadata,
1773            "https://zed.dev/oauth/client-metadata.json",
1774            "http://127.0.0.1:12345/callback",
1775            &["files:read".into(), "files:write".into()],
1776            "https://mcp.example.com",
1777            &pkce,
1778            "random_state_123",
1779        );
1780
1781        let pairs: std::collections::HashMap<_, _> = url.query_pairs().collect();
1782        assert_eq!(pairs.get("response_type").unwrap(), "code");
1783        assert_eq!(
1784            pairs.get("client_id").unwrap(),
1785            "https://zed.dev/oauth/client-metadata.json"
1786        );
1787        assert_eq!(
1788            pairs.get("redirect_uri").unwrap(),
1789            "http://127.0.0.1:12345/callback"
1790        );
1791        assert_eq!(pairs.get("scope").unwrap(), "files:read files:write");
1792        assert_eq!(pairs.get("resource").unwrap(), "https://mcp.example.com");
1793        assert_eq!(pairs.get("code_challenge").unwrap(), "test_challenge");
1794        assert_eq!(pairs.get("code_challenge_method").unwrap(), "S256");
1795        assert_eq!(pairs.get("state").unwrap(), "random_state_123");
1796    }
1797
1798    #[test]
1799    fn test_build_authorization_url_omits_empty_scope() {
1800        let metadata = AuthServerMetadata {
1801            issuer: Url::parse("https://auth.example.com").unwrap(),
1802            authorization_endpoint: Url::parse("https://auth.example.com/authorize").unwrap(),
1803            token_endpoint: Url::parse("https://auth.example.com/token").unwrap(),
1804            registration_endpoint: None,
1805            scopes_supported: None,
1806            code_challenge_methods_supported: Some(vec!["S256".into()]),
1807            client_id_metadata_document_supported: false,
1808            grant_types_supported: None,
1809        };
1810        let pkce = PkceChallenge {
1811            verifier: "v".into(),
1812            challenge: "c".into(),
1813        };
1814        let url = build_authorization_url(
1815            &metadata,
1816            "client_123",
1817            "http://127.0.0.1:9999/callback",
1818            &[],
1819            "https://mcp.example.com",
1820            &pkce,
1821            "state",
1822        );
1823
1824        let pairs: std::collections::HashMap<_, _> = url.query_pairs().collect();
1825        assert!(!pairs.contains_key("scope"));
1826    }
1827
1828    // -- Token exchange / refresh param tests --------------------------------
1829
1830    #[test]
1831    fn test_token_exchange_params() {
1832        let params = token_exchange_params(
1833            "auth_code_abc",
1834            "client_xyz",
1835            "http://127.0.0.1:5555/callback",
1836            "verifier_123",
1837            "https://mcp.example.com",
1838            None,
1839        );
1840        let map: std::collections::HashMap<&str, &str> =
1841            params.iter().map(|(k, v)| (*k, v.as_str())).collect();
1842
1843        assert_eq!(map["grant_type"], "authorization_code");
1844        assert_eq!(map["code"], "auth_code_abc");
1845        assert_eq!(map["redirect_uri"], "http://127.0.0.1:5555/callback");
1846        assert_eq!(map["client_id"], "client_xyz");
1847        assert_eq!(map["code_verifier"], "verifier_123");
1848        assert_eq!(map["resource"], "https://mcp.example.com");
1849    }
1850
1851    #[test]
1852    fn test_token_refresh_params() {
1853        let params = token_refresh_params(
1854            "refresh_token_abc",
1855            "client_xyz",
1856            "https://mcp.example.com",
1857            None,
1858        );
1859        let map: std::collections::HashMap<&str, &str> =
1860            params.iter().map(|(k, v)| (*k, v.as_str())).collect();
1861
1862        assert_eq!(map["grant_type"], "refresh_token");
1863        assert_eq!(map["refresh_token"], "refresh_token_abc");
1864        assert_eq!(map["client_id"], "client_xyz");
1865        assert_eq!(map["resource"], "https://mcp.example.com");
1866    }
1867
1868    // -- Token response tests ------------------------------------------------
1869
1870    #[test]
1871    fn test_token_response_into_tokens_with_expiry() {
1872        let response: TokenResponse = serde_json::from_str(
1873            r#"{"access_token": "at_123", "refresh_token": "rt_456", "expires_in": 3600, "token_type": "Bearer"}"#,
1874        )
1875        .unwrap();
1876
1877        let tokens = response.into_tokens();
1878        assert_eq!(tokens.access_token, "at_123");
1879        assert_eq!(tokens.refresh_token.as_deref(), Some("rt_456"));
1880        assert!(tokens.expires_at.is_some());
1881    }
1882
1883    #[test]
1884    fn test_token_response_into_tokens_minimal() {
1885        let response: TokenResponse =
1886            serde_json::from_str(r#"{"access_token": "at_789"}"#).unwrap();
1887
1888        let tokens = response.into_tokens();
1889        assert_eq!(tokens.access_token, "at_789");
1890        assert_eq!(tokens.refresh_token, None);
1891        assert_eq!(tokens.expires_at, None);
1892    }
1893
1894    // -- DCR body test -------------------------------------------------------
1895
1896    #[test]
1897    fn test_dcr_registration_body_without_server_metadata() {
1898        // When server metadata is unavailable, include all supported grant types.
1899        let body = dcr_registration_body("http://127.0.0.1:12345/callback", None);
1900        assert_eq!(body["client_name"], "Zed");
1901        assert_eq!(body["redirect_uris"][0], "http://127.0.0.1:12345/callback");
1902        assert_eq!(body["grant_types"][0], "authorization_code");
1903        assert_eq!(body["grant_types"][1], "refresh_token");
1904        assert_eq!(body["response_types"][0], "code");
1905        assert_eq!(body["token_endpoint_auth_method"], "none");
1906    }
1907
1908    #[test]
1909    fn test_dcr_registration_body_mirrors_server_grant_types() {
1910        // When the server only supports authorization_code, omit refresh_token.
1911        let server_types = vec!["authorization_code".to_string()];
1912        let body = dcr_registration_body("http://127.0.0.1:12345/callback", Some(&server_types));
1913        assert_eq!(body["grant_types"][0], "authorization_code");
1914        assert!(body["grant_types"].as_array().unwrap().len() == 1);
1915
1916        // When the server supports both, include both.
1917        let server_types = vec![
1918            "authorization_code".to_string(),
1919            "refresh_token".to_string(),
1920        ];
1921        let body = dcr_registration_body("http://127.0.0.1:12345/callback", Some(&server_types));
1922        assert_eq!(body["grant_types"][0], "authorization_code");
1923        assert_eq!(body["grant_types"][1], "refresh_token");
1924    }
1925
1926    // -- Test helpers for async/HTTP tests -----------------------------------
1927
1928    fn make_fake_http_client(
1929        handler: impl Fn(
1930            http_client::Request<AsyncBody>,
1931        ) -> std::pin::Pin<
1932            Box<dyn std::future::Future<Output = anyhow::Result<Response<AsyncBody>>> + Send>,
1933        > + Send
1934        + Sync
1935        + 'static,
1936    ) -> Arc<dyn HttpClient> {
1937        http_client::FakeHttpClient::create(handler) as Arc<dyn HttpClient>
1938    }
1939
1940    fn json_response(status: u16, body: &str) -> anyhow::Result<Response<AsyncBody>> {
1941        Ok(Response::builder()
1942            .status(status)
1943            .header("Content-Type", "application/json")
1944            .body(AsyncBody::from(body.as_bytes().to_vec()))
1945            .unwrap())
1946    }
1947
1948    // -- Discovery integration tests -----------------------------------------
1949
1950    #[test]
1951    fn test_fetch_protected_resource_metadata() {
1952        gpui::block_on(async {
1953            let client = make_fake_http_client(|req| {
1954                Box::pin(async move {
1955                    let uri = req.uri().to_string();
1956                    if uri.contains(".well-known/oauth-protected-resource") {
1957                        json_response(
1958                            200,
1959                            r#"{
1960                                "resource": "https://mcp.example.com",
1961                                "authorization_servers": ["https://auth.example.com"],
1962                                "scopes_supported": ["read", "write"]
1963                            }"#,
1964                        )
1965                    } else {
1966                        json_response(404, "{}")
1967                    }
1968                })
1969            });
1970
1971            let server_url = Url::parse("https://mcp.example.com").unwrap();
1972            let www_auth = WwwAuthenticate {
1973                resource_metadata: None,
1974                scope: None,
1975                error: None,
1976                error_description: None,
1977            };
1978
1979            let metadata = fetch_protected_resource_metadata(&client, &server_url, &www_auth)
1980                .await
1981                .unwrap();
1982
1983            assert_eq!(metadata.resource.as_str(), "https://mcp.example.com/");
1984            assert_eq!(metadata.authorization_servers.len(), 1);
1985            assert_eq!(
1986                metadata.authorization_servers[0].as_str(),
1987                "https://auth.example.com/"
1988            );
1989            assert_eq!(
1990                metadata.scopes_supported,
1991                Some(vec!["read".to_string(), "write".to_string()])
1992            );
1993        });
1994    }
1995
1996    #[test]
1997    fn test_fetch_protected_resource_metadata_prefers_www_authenticate_url() {
1998        gpui::block_on(async {
1999            let client = make_fake_http_client(|req| {
2000                Box::pin(async move {
2001                    let uri = req.uri().to_string();
2002                    if uri == "https://mcp.example.com/custom-resource-metadata" {
2003                        json_response(
2004                            200,
2005                            r#"{
2006                                "resource": "https://mcp.example.com",
2007                                "authorization_servers": ["https://auth.example.com"]
2008                            }"#,
2009                        )
2010                    } else {
2011                        json_response(500, r#"{"error": "should not be called"}"#)
2012                    }
2013                })
2014            });
2015
2016            let server_url = Url::parse("https://mcp.example.com").unwrap();
2017            let www_auth = WwwAuthenticate {
2018                resource_metadata: Some(
2019                    Url::parse("https://mcp.example.com/custom-resource-metadata").unwrap(),
2020                ),
2021                scope: None,
2022                error: None,
2023                error_description: None,
2024            };
2025
2026            let metadata = fetch_protected_resource_metadata(&client, &server_url, &www_auth)
2027                .await
2028                .unwrap();
2029
2030            assert_eq!(metadata.authorization_servers.len(), 1);
2031        });
2032    }
2033
2034    #[test]
2035    fn test_fetch_protected_resource_metadata_falls_back_when_header_url_fails() {
2036        // Reproduces the Pydantic Logfire case: the server's WWW-Authenticate
2037        // header contains a resource_metadata URL with a doubled path (e.g.
2038        // /mcp/mcp), which returns HTML instead of JSON. The client should
2039        // fall back to the RFC 9728 well-known URL, which works correctly.
2040        gpui::block_on(async {
2041            let client = make_fake_http_client(|req| {
2042                Box::pin(async move {
2043                    let uri = req.uri().to_string();
2044                    if uri
2045                        == "https://mcp.example.com/.well-known/oauth-protected-resource/api/mcp/mcp"
2046                    {
2047                        // Buggy header URL returns HTML (like a SPA catch-all).
2048                        Ok(Response::builder()
2049                            .status(200)
2050                            .header("Content-Type", "text/html")
2051                            .body(AsyncBody::from(b"<!doctype html><html></html>".to_vec()))
2052                            .unwrap())
2053                    } else if uri
2054                        == "https://mcp.example.com/.well-known/oauth-protected-resource/api/mcp"
2055                    {
2056                        // Correct well-known URL returns valid metadata.
2057                        json_response(
2058                            200,
2059                            r#"{
2060                                "resource": "https://mcp.example.com/api/mcp",
2061                                "authorization_servers": ["https://auth.example.com"]
2062                            }"#,
2063                        )
2064                    } else {
2065                        json_response(404, "{}")
2066                    }
2067                })
2068            });
2069
2070            let server_url = Url::parse("https://mcp.example.com/api/mcp").unwrap();
2071            let www_auth = WwwAuthenticate {
2072                resource_metadata: Some(
2073                    // Buggy URL with doubled path component.
2074                    Url::parse(
2075                        "https://mcp.example.com/.well-known/oauth-protected-resource/api/mcp/mcp",
2076                    )
2077                    .unwrap(),
2078                ),
2079                scope: None,
2080                error: None,
2081                error_description: None,
2082            };
2083
2084            let metadata = fetch_protected_resource_metadata(&client, &server_url, &www_auth)
2085                .await
2086                .unwrap();
2087
2088            assert_eq!(
2089                metadata.resource.as_str(),
2090                "https://mcp.example.com/api/mcp"
2091            );
2092            assert_eq!(
2093                metadata.authorization_servers[0].as_str(),
2094                "https://auth.example.com/"
2095            );
2096        });
2097    }
2098
2099    #[test]
2100    fn test_fetch_protected_resource_metadata_rejects_cross_origin_url() {
2101        gpui::block_on(async {
2102            let client = make_fake_http_client(|req| {
2103                Box::pin(async move {
2104                    let uri = req.uri().to_string();
2105                    // The cross-origin URL should NOT be fetched; only the
2106                    // well-known fallback at the server's own origin should be.
2107                    if uri.contains("attacker.example.com") {
2108                        panic!("should not fetch cross-origin resource_metadata URL");
2109                    } else if uri.contains(".well-known/oauth-protected-resource") {
2110                        json_response(
2111                            200,
2112                            r#"{
2113                                "resource": "https://mcp.example.com",
2114                                "authorization_servers": ["https://auth.example.com"]
2115                            }"#,
2116                        )
2117                    } else {
2118                        json_response(404, "{}")
2119                    }
2120                })
2121            });
2122
2123            let server_url = Url::parse("https://mcp.example.com").unwrap();
2124            let www_auth = WwwAuthenticate {
2125                resource_metadata: Some(
2126                    Url::parse("https://attacker.example.com/fake-metadata").unwrap(),
2127                ),
2128                scope: None,
2129                error: None,
2130                error_description: None,
2131            };
2132
2133            let metadata = fetch_protected_resource_metadata(&client, &server_url, &www_auth)
2134                .await
2135                .unwrap();
2136
2137            // Should have used the fallback well-known URL, not the attacker's.
2138            assert_eq!(metadata.resource.as_str(), "https://mcp.example.com/");
2139        });
2140    }
2141
2142    #[test]
2143    fn test_fetch_auth_server_metadata() {
2144        gpui::block_on(async {
2145            let client = make_fake_http_client(|req| {
2146                Box::pin(async move {
2147                    let uri = req.uri().to_string();
2148                    if uri.contains(".well-known/oauth-authorization-server") {
2149                        json_response(
2150                            200,
2151                            r#"{
2152                                "issuer": "https://auth.example.com",
2153                                "authorization_endpoint": "https://auth.example.com/authorize",
2154                                "token_endpoint": "https://auth.example.com/token",
2155                                "registration_endpoint": "https://auth.example.com/register",
2156                                "code_challenge_methods_supported": ["S256"],
2157                                "client_id_metadata_document_supported": true
2158                            }"#,
2159                        )
2160                    } else {
2161                        json_response(404, "{}")
2162                    }
2163                })
2164            });
2165
2166            let issuer = Url::parse("https://auth.example.com").unwrap();
2167            let metadata = fetch_auth_server_metadata(&client, &issuer).await.unwrap();
2168
2169            assert_eq!(metadata.issuer.as_str(), "https://auth.example.com/");
2170            assert_eq!(
2171                metadata.authorization_endpoint.as_str(),
2172                "https://auth.example.com/authorize"
2173            );
2174            assert_eq!(
2175                metadata.token_endpoint.as_str(),
2176                "https://auth.example.com/token"
2177            );
2178            assert!(metadata.registration_endpoint.is_some());
2179            assert!(metadata.client_id_metadata_document_supported);
2180            assert_eq!(
2181                metadata.code_challenge_methods_supported,
2182                Some(vec!["S256".to_string()])
2183            );
2184        });
2185    }
2186
2187    #[test]
2188    fn test_fetch_auth_server_metadata_falls_back_to_oidc() {
2189        gpui::block_on(async {
2190            let client = make_fake_http_client(|req| {
2191                Box::pin(async move {
2192                    let uri = req.uri().to_string();
2193                    if uri.contains("openid-configuration") {
2194                        json_response(
2195                            200,
2196                            r#"{
2197                                "issuer": "https://auth.example.com",
2198                                "authorization_endpoint": "https://auth.example.com/authorize",
2199                                "token_endpoint": "https://auth.example.com/token",
2200                                "code_challenge_methods_supported": ["S256"]
2201                            }"#,
2202                        )
2203                    } else {
2204                        json_response(404, "{}")
2205                    }
2206                })
2207            });
2208
2209            let issuer = Url::parse("https://auth.example.com").unwrap();
2210            let metadata = fetch_auth_server_metadata(&client, &issuer).await.unwrap();
2211
2212            assert_eq!(
2213                metadata.authorization_endpoint.as_str(),
2214                "https://auth.example.com/authorize"
2215            );
2216            assert!(!metadata.client_id_metadata_document_supported);
2217        });
2218    }
2219
2220    #[test]
2221    fn test_fetch_auth_server_metadata_rejects_issuer_mismatch() {
2222        gpui::block_on(async {
2223            let client = make_fake_http_client(|req| {
2224                Box::pin(async move {
2225                    let uri = req.uri().to_string();
2226                    if uri.contains(".well-known/oauth-authorization-server") {
2227                        // Response claims to be a different issuer.
2228                        json_response(
2229                            200,
2230                            r#"{
2231                                "issuer": "https://evil.example.com",
2232                                "authorization_endpoint": "https://evil.example.com/authorize",
2233                                "token_endpoint": "https://evil.example.com/token",
2234                                "code_challenge_methods_supported": ["S256"]
2235                            }"#,
2236                        )
2237                    } else {
2238                        json_response(404, "{}")
2239                    }
2240                })
2241            });
2242
2243            let issuer = Url::parse("https://auth.example.com").unwrap();
2244            let result = fetch_auth_server_metadata(&client, &issuer).await;
2245
2246            assert!(result.is_err());
2247            let err_msg = result.unwrap_err().to_string();
2248            assert!(
2249                err_msg.contains("issuer mismatch"),
2250                "unexpected error: {}",
2251                err_msg
2252            );
2253        });
2254    }
2255
2256    // -- Full discover integration tests -------------------------------------
2257
2258    #[test]
2259    fn test_full_discover_with_cimd() {
2260        gpui::block_on(async {
2261            let client = make_fake_http_client(|req| {
2262                Box::pin(async move {
2263                    let uri = req.uri().to_string();
2264                    if uri.contains("oauth-protected-resource") {
2265                        json_response(
2266                            200,
2267                            r#"{
2268                                "resource": "https://mcp.example.com",
2269                                "authorization_servers": ["https://auth.example.com"],
2270                                "scopes_supported": ["mcp:read"]
2271                            }"#,
2272                        )
2273                    } else if uri.contains("oauth-authorization-server") {
2274                        json_response(
2275                            200,
2276                            r#"{
2277                                "issuer": "https://auth.example.com",
2278                                "authorization_endpoint": "https://auth.example.com/authorize",
2279                                "token_endpoint": "https://auth.example.com/token",
2280                                "code_challenge_methods_supported": ["S256"],
2281                                "client_id_metadata_document_supported": true
2282                            }"#,
2283                        )
2284                    } else {
2285                        json_response(404, "{}")
2286                    }
2287                })
2288            });
2289
2290            let server_url = Url::parse("https://mcp.example.com").unwrap();
2291            let www_auth = WwwAuthenticate {
2292                resource_metadata: None,
2293                scope: None,
2294                error: None,
2295                error_description: None,
2296            };
2297
2298            let discovery = discover(&client, &server_url, &www_auth).await.unwrap();
2299            let registration =
2300                resolve_client_registration(&client, &discovery, "http://127.0.0.1:12345/callback")
2301                    .await
2302                    .unwrap();
2303
2304            assert_eq!(registration.client_id, CIMD_URL);
2305            assert_eq!(registration.client_secret, None);
2306            assert_eq!(discovery.scopes, vec!["mcp:read"]);
2307        });
2308    }
2309
2310    #[test]
2311    fn test_full_discover_with_dcr_fallback() {
2312        gpui::block_on(async {
2313            let client = make_fake_http_client(|req| {
2314                Box::pin(async move {
2315                    let uri = req.uri().to_string();
2316                    if uri.contains("oauth-protected-resource") {
2317                        json_response(
2318                            200,
2319                            r#"{
2320                                "resource": "https://mcp.example.com",
2321                                "authorization_servers": ["https://auth.example.com"]
2322                            }"#,
2323                        )
2324                    } else if uri.contains("oauth-authorization-server") {
2325                        json_response(
2326                            200,
2327                            r#"{
2328                                "issuer": "https://auth.example.com",
2329                                "authorization_endpoint": "https://auth.example.com/authorize",
2330                                "token_endpoint": "https://auth.example.com/token",
2331                                "registration_endpoint": "https://auth.example.com/register",
2332                                "code_challenge_methods_supported": ["S256"],
2333                                "client_id_metadata_document_supported": false
2334                            }"#,
2335                        )
2336                    } else if uri.contains("/register") {
2337                        json_response(
2338                            201,
2339                            r#"{
2340                                "client_id": "dcr-minted-id-123",
2341                                "client_secret": "dcr-secret-456"
2342                            }"#,
2343                        )
2344                    } else {
2345                        json_response(404, "{}")
2346                    }
2347                })
2348            });
2349
2350            let server_url = Url::parse("https://mcp.example.com").unwrap();
2351            let www_auth = WwwAuthenticate {
2352                resource_metadata: None,
2353                scope: Some(vec!["files:read".into()]),
2354                error: None,
2355                error_description: None,
2356            };
2357
2358            let discovery = discover(&client, &server_url, &www_auth).await.unwrap();
2359            let registration =
2360                resolve_client_registration(&client, &discovery, "http://127.0.0.1:9999/callback")
2361                    .await
2362                    .unwrap();
2363
2364            assert_eq!(registration.client_id, "dcr-minted-id-123");
2365            assert_eq!(
2366                registration.client_secret.as_deref(),
2367                Some("dcr-secret-456")
2368            );
2369            assert_eq!(discovery.scopes, vec!["files:read"]);
2370        });
2371    }
2372
2373    #[test]
2374    fn test_discover_fails_without_pkce_support() {
2375        gpui::block_on(async {
2376            let client = make_fake_http_client(|req| {
2377                Box::pin(async move {
2378                    let uri = req.uri().to_string();
2379                    if uri.contains("oauth-protected-resource") {
2380                        json_response(
2381                            200,
2382                            r#"{
2383                                "resource": "https://mcp.example.com",
2384                                "authorization_servers": ["https://auth.example.com"]
2385                            }"#,
2386                        )
2387                    } else if uri.contains("oauth-authorization-server") {
2388                        json_response(
2389                            200,
2390                            r#"{
2391                                "issuer": "https://auth.example.com",
2392                                "authorization_endpoint": "https://auth.example.com/authorize",
2393                                "token_endpoint": "https://auth.example.com/token"
2394                            }"#,
2395                        )
2396                    } else {
2397                        json_response(404, "{}")
2398                    }
2399                })
2400            });
2401
2402            let server_url = Url::parse("https://mcp.example.com").unwrap();
2403            let www_auth = WwwAuthenticate {
2404                resource_metadata: None,
2405                scope: None,
2406                error: None,
2407                error_description: None,
2408            };
2409
2410            let result = discover(&client, &server_url, &www_auth).await;
2411            assert!(result.is_err());
2412            let err_msg = result.unwrap_err().to_string();
2413            assert!(
2414                err_msg.contains("code_challenge_methods_supported"),
2415                "unexpected error: {}",
2416                err_msg
2417            );
2418        });
2419    }
2420
2421    // -- Token exchange integration tests ------------------------------------
2422
2423    #[test]
2424    fn test_exchange_code_success() {
2425        gpui::block_on(async {
2426            let client = make_fake_http_client(|req| {
2427                Box::pin(async move {
2428                    let uri = req.uri().to_string();
2429                    if uri.contains("/token") {
2430                        json_response(
2431                            200,
2432                            r#"{
2433                                "access_token": "new_access_token",
2434                                "refresh_token": "new_refresh_token",
2435                                "expires_in": 3600,
2436                                "token_type": "Bearer"
2437                            }"#,
2438                        )
2439                    } else {
2440                        json_response(404, "{}")
2441                    }
2442                })
2443            });
2444
2445            let metadata = AuthServerMetadata {
2446                issuer: Url::parse("https://auth.example.com").unwrap(),
2447                authorization_endpoint: Url::parse("https://auth.example.com/authorize").unwrap(),
2448                token_endpoint: Url::parse("https://auth.example.com/token").unwrap(),
2449                registration_endpoint: None,
2450                scopes_supported: None,
2451                code_challenge_methods_supported: Some(vec!["S256".into()]),
2452                client_id_metadata_document_supported: true,
2453                grant_types_supported: None,
2454            };
2455
2456            let tokens = exchange_code(
2457                &client,
2458                &metadata,
2459                "auth_code_123",
2460                CIMD_URL,
2461                "http://127.0.0.1:9999/callback",
2462                "verifier_abc",
2463                "https://mcp.example.com",
2464                None,
2465            )
2466            .await
2467            .unwrap();
2468
2469            assert_eq!(tokens.access_token, "new_access_token");
2470            assert_eq!(tokens.refresh_token.as_deref(), Some("new_refresh_token"));
2471            assert!(tokens.expires_at.is_some());
2472        });
2473    }
2474
2475    #[test]
2476    fn test_refresh_tokens_success() {
2477        gpui::block_on(async {
2478            let client = make_fake_http_client(|req| {
2479                Box::pin(async move {
2480                    let uri = req.uri().to_string();
2481                    if uri.contains("/token") {
2482                        json_response(
2483                            200,
2484                            r#"{
2485                                "access_token": "refreshed_token",
2486                                "expires_in": 1800,
2487                                "token_type": "Bearer"
2488                            }"#,
2489                        )
2490                    } else {
2491                        json_response(404, "{}")
2492                    }
2493                })
2494            });
2495
2496            let token_endpoint = Url::parse("https://auth.example.com/token").unwrap();
2497
2498            let tokens = refresh_tokens(
2499                &client,
2500                &token_endpoint,
2501                "old_refresh_token",
2502                CIMD_URL,
2503                "https://mcp.example.com",
2504                None,
2505            )
2506            .await
2507            .unwrap();
2508
2509            assert_eq!(tokens.access_token, "refreshed_token");
2510            assert_eq!(tokens.refresh_token, None);
2511            assert!(tokens.expires_at.is_some());
2512        });
2513    }
2514
2515    #[test]
2516    fn test_exchange_code_failure() {
2517        gpui::block_on(async {
2518            let client = make_fake_http_client(|_req| {
2519                Box::pin(async move { json_response(400, r#"{"error": "invalid_grant"}"#) })
2520            });
2521
2522            let metadata = AuthServerMetadata {
2523                issuer: Url::parse("https://auth.example.com").unwrap(),
2524                authorization_endpoint: Url::parse("https://auth.example.com/authorize").unwrap(),
2525                token_endpoint: Url::parse("https://auth.example.com/token").unwrap(),
2526                registration_endpoint: None,
2527                scopes_supported: None,
2528                code_challenge_methods_supported: Some(vec!["S256".into()]),
2529                client_id_metadata_document_supported: true,
2530                grant_types_supported: None,
2531            };
2532
2533            let result = exchange_code(
2534                &client,
2535                &metadata,
2536                "bad_code",
2537                "client",
2538                "http://127.0.0.1:1/callback",
2539                "verifier",
2540                "https://mcp.example.com",
2541                None,
2542            )
2543            .await;
2544
2545            let err = result.unwrap_err();
2546            let token_error = err
2547                .downcast_ref::<OAuthTokenError>()
2548                .expect("expected OAuthTokenError");
2549            assert_eq!(
2550                *token_error,
2551                OAuthTokenError {
2552                    error: "invalid_grant".into(),
2553                    error_description: None,
2554                }
2555            );
2556        });
2557    }
2558
2559    // -- DCR integration tests -----------------------------------------------
2560
2561    #[test]
2562    fn test_perform_dcr() {
2563        gpui::block_on(async {
2564            let client = make_fake_http_client(|_req| {
2565                Box::pin(async move {
2566                    json_response(
2567                        201,
2568                        r#"{
2569                            "client_id": "dynamic-client-001",
2570                            "client_secret": "dynamic-secret-001"
2571                        }"#,
2572                    )
2573                })
2574            });
2575
2576            let endpoint = Url::parse("https://auth.example.com/register").unwrap();
2577            let registration =
2578                perform_dcr(&client, &endpoint, "http://127.0.0.1:9999/callback", None)
2579                    .await
2580                    .unwrap();
2581
2582            assert_eq!(registration.client_id, "dynamic-client-001");
2583            assert_eq!(
2584                registration.client_secret.as_deref(),
2585                Some("dynamic-secret-001")
2586            );
2587        });
2588    }
2589
2590    #[test]
2591    fn test_perform_dcr_failure() {
2592        gpui::block_on(async {
2593            let client = make_fake_http_client(|_req| {
2594                Box::pin(
2595                    async move { json_response(403, r#"{"error": "registration_not_allowed"}"#) },
2596                )
2597            });
2598
2599            let endpoint = Url::parse("https://auth.example.com/register").unwrap();
2600            let result =
2601                perform_dcr(&client, &endpoint, "http://127.0.0.1:9999/callback", None).await;
2602
2603            assert!(result.is_err());
2604            assert!(result.unwrap_err().to_string().contains("403"));
2605        });
2606    }
2607
2608    // -- OAuthCallback parse tests -------------------------------------------
2609
2610    #[test]
2611    fn test_oauth_callback_parse_query() {
2612        let callback = OAuthCallback::parse_query("code=test_auth_code&state=test_state").unwrap();
2613        assert_eq!(callback.code, "test_auth_code");
2614        assert_eq!(callback.state, "test_state");
2615    }
2616
2617    #[test]
2618    fn test_oauth_callback_parse_query_reversed_order() {
2619        let callback = OAuthCallback::parse_query("state=test_state&code=test_auth_code").unwrap();
2620        assert_eq!(callback.code, "test_auth_code");
2621        assert_eq!(callback.state, "test_state");
2622    }
2623
2624    #[test]
2625    fn test_oauth_callback_parse_query_with_extra_params() {
2626        let callback =
2627            OAuthCallback::parse_query("code=test_auth_code&state=test_state&extra=ignored")
2628                .unwrap();
2629        assert_eq!(callback.code, "test_auth_code");
2630        assert_eq!(callback.state, "test_state");
2631    }
2632
2633    #[test]
2634    fn test_oauth_callback_parse_query_missing_code() {
2635        let result = OAuthCallback::parse_query("state=test_state");
2636        assert!(result.is_err());
2637        assert!(result.unwrap_err().to_string().contains("code"));
2638    }
2639
2640    #[test]
2641    fn test_oauth_callback_parse_query_missing_state() {
2642        let result = OAuthCallback::parse_query("code=test_auth_code");
2643        assert!(result.is_err());
2644        assert!(result.unwrap_err().to_string().contains("state"));
2645    }
2646
2647    #[test]
2648    fn test_oauth_callback_parse_query_empty_code() {
2649        let result = OAuthCallback::parse_query("code=&state=test_state");
2650        assert!(result.is_err());
2651    }
2652
2653    #[test]
2654    fn test_oauth_callback_parse_query_empty_state() {
2655        let result = OAuthCallback::parse_query("code=test_auth_code&state=");
2656        assert!(result.is_err());
2657    }
2658
2659    #[test]
2660    fn test_oauth_callback_parse_query_url_encoded_values() {
2661        let callback = OAuthCallback::parse_query("code=abc%20def&state=test%3Dstate").unwrap();
2662        assert_eq!(callback.code, "abc def");
2663        assert_eq!(callback.state, "test=state");
2664    }
2665
2666    #[test]
2667    fn test_oauth_callback_parse_query_error_response() {
2668        let result = OAuthCallback::parse_query(
2669            "error=access_denied&error_description=User%20denied%20access&state=abc",
2670        );
2671        assert!(result.is_err());
2672        let err_msg = result.unwrap_err().to_string();
2673        assert!(
2674            err_msg.contains("access_denied"),
2675            "unexpected error: {}",
2676            err_msg
2677        );
2678        assert!(
2679            err_msg.contains("User denied access"),
2680            "unexpected error: {}",
2681            err_msg
2682        );
2683    }
2684
2685    #[test]
2686    fn test_oauth_callback_parse_query_error_without_description() {
2687        let result = OAuthCallback::parse_query("error=server_error&state=abc");
2688        assert!(result.is_err());
2689        let err_msg = result.unwrap_err().to_string();
2690        assert!(
2691            err_msg.contains("server_error"),
2692            "unexpected error: {}",
2693            err_msg
2694        );
2695        assert!(
2696            err_msg.contains("no description"),
2697            "unexpected error: {}",
2698            err_msg
2699        );
2700    }
2701
2702    // -- McpOAuthTokenProvider tests -----------------------------------------
2703
2704    fn make_test_session(
2705        access_token: &str,
2706        refresh_token: Option<&str>,
2707        expires_at: Option<SystemTime>,
2708    ) -> OAuthSession {
2709        OAuthSession {
2710            token_endpoint: Url::parse("https://auth.example.com/token").unwrap(),
2711            resource: Url::parse("https://mcp.example.com").unwrap(),
2712            client_registration: OAuthClientRegistration {
2713                client_id: "test-client".into(),
2714                client_secret: None,
2715            },
2716            tokens: OAuthTokens {
2717                access_token: access_token.into(),
2718                refresh_token: refresh_token.map(String::from),
2719                expires_at,
2720            },
2721        }
2722    }
2723
2724    #[test]
2725    fn test_mcp_oauth_provider_returns_none_when_token_expired() {
2726        let expired = SystemTime::now() - Duration::from_secs(60);
2727        let session = make_test_session("stale-token", Some("rt"), Some(expired));
2728        let provider = McpOAuthTokenProvider::new(
2729            session,
2730            make_fake_http_client(|_| Box::pin(async { unreachable!() })),
2731            None,
2732        );
2733
2734        assert_eq!(provider.access_token(), None);
2735    }
2736
2737    #[test]
2738    fn test_mcp_oauth_provider_returns_token_when_not_expired() {
2739        let far_future = SystemTime::now() + Duration::from_secs(3600);
2740        let session = make_test_session("valid-token", Some("rt"), Some(far_future));
2741        let provider = McpOAuthTokenProvider::new(
2742            session,
2743            make_fake_http_client(|_| Box::pin(async { unreachable!() })),
2744            None,
2745        );
2746
2747        assert_eq!(provider.access_token().as_deref(), Some("valid-token"));
2748    }
2749
2750    #[test]
2751    fn test_mcp_oauth_provider_returns_token_when_no_expiry() {
2752        let session = make_test_session("no-expiry-token", Some("rt"), None);
2753        let provider = McpOAuthTokenProvider::new(
2754            session,
2755            make_fake_http_client(|_| Box::pin(async { unreachable!() })),
2756            None,
2757        );
2758
2759        assert_eq!(provider.access_token().as_deref(), Some("no-expiry-token"));
2760    }
2761
2762    #[test]
2763    fn test_mcp_oauth_provider_refresh_without_refresh_token_returns_false() {
2764        gpui::block_on(async {
2765            let session = make_test_session("token", None, None);
2766            let provider = McpOAuthTokenProvider::new(
2767                session,
2768                make_fake_http_client(|_| {
2769                    Box::pin(async { unreachable!("no HTTP call expected") })
2770                }),
2771                None,
2772            );
2773
2774            let refreshed = provider.try_refresh().await.unwrap();
2775            assert!(!refreshed);
2776        });
2777    }
2778
2779    #[test]
2780    fn test_mcp_oauth_provider_refresh_updates_session_and_notifies_channel() {
2781        gpui::block_on(async {
2782            let session = make_test_session("old-access", Some("my-refresh-token"), None);
2783            let (tx, mut rx) = futures::channel::mpsc::unbounded();
2784
2785            let http_client = make_fake_http_client(|_req| {
2786                Box::pin(async {
2787                    json_response(
2788                        200,
2789                        r#"{
2790                            "access_token": "new-access",
2791                            "refresh_token": "new-refresh",
2792                            "expires_in": 1800
2793                        }"#,
2794                    )
2795                })
2796            });
2797
2798            let provider = McpOAuthTokenProvider::new(session, http_client, Some(tx));
2799
2800            let refreshed = provider.try_refresh().await.unwrap();
2801            assert!(refreshed);
2802            assert_eq!(provider.access_token().as_deref(), Some("new-access"));
2803
2804            let notified_session = rx.try_recv().expect("channel should have a session");
2805            assert_eq!(notified_session.tokens.access_token, "new-access");
2806            assert_eq!(
2807                notified_session.tokens.refresh_token.as_deref(),
2808                Some("new-refresh")
2809            );
2810        });
2811    }
2812
2813    #[test]
2814    fn test_mcp_oauth_provider_refresh_preserves_old_refresh_token_when_server_omits_it() {
2815        gpui::block_on(async {
2816            let session = make_test_session("old-access", Some("original-refresh"), None);
2817            let (tx, mut rx) = futures::channel::mpsc::unbounded();
2818
2819            let http_client = make_fake_http_client(|_req| {
2820                Box::pin(async {
2821                    json_response(
2822                        200,
2823                        r#"{
2824                            "access_token": "new-access",
2825                            "expires_in": 900
2826                        }"#,
2827                    )
2828                })
2829            });
2830
2831            let provider = McpOAuthTokenProvider::new(session, http_client, Some(tx));
2832
2833            let refreshed = provider.try_refresh().await.unwrap();
2834            assert!(refreshed);
2835
2836            let notified_session = rx.try_recv().expect("channel should have a session");
2837            assert_eq!(notified_session.tokens.access_token, "new-access");
2838            assert_eq!(
2839                notified_session.tokens.refresh_token.as_deref(),
2840                Some("original-refresh"),
2841            );
2842        });
2843    }
2844
2845    #[test]
2846    fn test_mcp_oauth_provider_refresh_returns_false_on_http_error() {
2847        gpui::block_on(async {
2848            let session = make_test_session("old-access", Some("my-refresh"), None);
2849
2850            let http_client = make_fake_http_client(|_req| {
2851                Box::pin(async { json_response(401, r#"{"error": "invalid_grant"}"#) })
2852            });
2853
2854            let provider = McpOAuthTokenProvider::new(session, http_client, None);
2855
2856            let refreshed = provider.try_refresh().await.unwrap();
2857            assert!(!refreshed);
2858            // The old token should still be in place.
2859            assert_eq!(provider.access_token().as_deref(), Some("old-access"));
2860        });
2861    }
2862}
2863
Served at tenant.openagents/omega Member data and write actions are omitted.