Skip to main content

atomcode_core/mcp/
oauth.rs

1//! OAuth token storage and login helpers for remote MCP.
2
3use std::collections::BTreeMap;
4use std::io::{Read, Write};
5use std::net::TcpListener;
6use std::path::PathBuf;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use anyhow::{bail, Context, Result};
10use serde::{Deserialize, Serialize};
11use serde_json::json;
12use sha2::{Digest, Sha256};
13use url::Url;
14use uuid::Uuid;
15
16use super::config::{McpHttpAuthConfig, McpOAuthConfig, McpServerConfig, McpTransportConfig};
17
18const GITHUB_AUTHORIZE_URL: &str = "https://github.com/login/oauth/authorize";
19const GITHUB_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
20const GITHUB_MCP_RESOURCE: &str = "https://api.githubcopilot.com/mcp/";
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct McpOAuthToken {
24    #[serde(default)]
25    pub provider: String,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub issuer: Option<String>,
28    #[serde(default = "default_token_type")]
29    pub token_type: String,
30    pub access_token: String,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub refresh_token: Option<String>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub expires_at: Option<i64>,
35    #[serde(default)]
36    pub scopes: Vec<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub resource: Option<String>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub client_id: Option<String>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub client_secret_env: Option<String>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub token_endpoint: Option<String>,
45}
46
47#[derive(Debug, Clone, Default)]
48pub struct McpOAuthLoginOptions {
49    pub client_id: Option<String>,
50    pub client_secret_env: Option<String>,
51    pub scopes: Vec<String>,
52}
53
54#[derive(Debug, Default, Serialize, Deserialize)]
55struct McpAuthFile {
56    #[serde(default)]
57    servers: BTreeMap<String, McpOAuthToken>,
58}
59
60#[derive(Debug, Deserialize)]
61struct TokenResponse {
62    access_token: String,
63    #[serde(default)]
64    refresh_token: Option<String>,
65    #[serde(default)]
66    token_type: Option<String>,
67    #[serde(default)]
68    expires_in: Option<i64>,
69    #[serde(default)]
70    scope: String,
71}
72
73#[derive(Debug, Deserialize)]
74struct ClientRegistrationResponse {
75    client_id: String,
76    #[serde(default)]
77    #[serde(rename = "client_secret")]
78    _client_secret: Option<String>,
79}
80
81#[derive(Debug, Clone, Deserialize)]
82struct ProtectedResourceMetadata {
83    #[serde(default)]
84    resource: Option<String>,
85    #[serde(default)]
86    authorization_servers: Vec<String>,
87}
88
89#[derive(Debug, Clone, Deserialize)]
90struct AuthorizationServerMetadata {
91    #[serde(default)]
92    issuer: Option<String>,
93    authorization_endpoint: String,
94    token_endpoint: String,
95    #[serde(default)]
96    registration_endpoint: Option<String>,
97    #[serde(default)]
98    #[serde(rename = "scopes_supported")]
99    _scopes_supported: Vec<String>,
100}
101
102pub struct McpTokenStore {
103    path: PathBuf,
104}
105
106impl McpTokenStore {
107    pub fn default_path() -> PathBuf {
108        crate::config::Config::config_dir().join("mcp_auth.toml")
109    }
110
111    pub fn new(path: PathBuf) -> Self {
112        Self { path }
113    }
114
115    pub fn default() -> Self {
116        Self::new(Self::default_path())
117    }
118
119    pub fn load_token(&self, server_name: &str) -> Result<Option<McpOAuthToken>> {
120        Ok(self.load_file()?.servers.remove(server_name))
121    }
122
123    pub fn save_token(&self, server_name: &str, token: McpOAuthToken) -> Result<()> {
124        let mut file = self.load_file()?;
125        file.servers.insert(server_name.to_string(), token);
126        self.save_file(&file)
127    }
128
129    pub fn delete_token(&self, server_name: &str) -> Result<bool> {
130        let mut file = self.load_file()?;
131        let removed = file.servers.remove(server_name).is_some();
132        self.save_file(&file)?;
133        Ok(removed)
134    }
135
136    fn load_file(&self) -> Result<McpAuthFile> {
137        if !self.path.exists() {
138            return Ok(McpAuthFile::default());
139        }
140        let text = std::fs::read_to_string(&self.path)
141            .with_context(|| format!("Failed to read {}", self.path.display()))?;
142        toml::from_str(&text).with_context(|| format!("Invalid {}", self.path.display()))
143    }
144
145    fn save_file(&self, file: &McpAuthFile) -> Result<()> {
146        if let Some(parent) = self.path.parent() {
147            std::fs::create_dir_all(parent)
148                .with_context(|| format!("Failed to create {}", parent.display()))?;
149        }
150        let text = toml::to_string_pretty(file).context("Failed to serialize MCP auth")?;
151        std::fs::write(&self.path, text)
152            .with_context(|| format!("Failed to write {}", self.path.display()))
153    }
154}
155
156pub fn token_is_expired(token: &McpOAuthToken) -> bool {
157    let Some(expires_at) = token.expires_at else {
158        return false;
159    };
160    now_unix() + 60 >= expires_at
161}
162
163/// Refresh an expired MCP OAuth token when refresh metadata is available.
164pub fn refresh_mcp_oauth_token(server_name: &str, token: &McpOAuthToken) -> Result<McpOAuthToken> {
165    let Some(refresh_token) = token.refresh_token.as_deref() else {
166        bail!(
167            "MCP server {} OAuth token is expired and has no refresh token",
168            server_name
169        );
170    };
171    let Some(token_endpoint) = token.token_endpoint.as_deref() else {
172        bail!(
173            "MCP server {} OAuth token is expired and has no saved token endpoint",
174            server_name
175        );
176    };
177    let Some(client_id) = token.client_id.as_deref() else {
178        bail!(
179            "MCP server {} OAuth token is expired and has no saved client id",
180            server_name
181        );
182    };
183
184    let client_secret = token
185        .client_secret_env
186        .as_deref()
187        .and_then(|name| std::env::var(name).ok());
188    let mut form = vec![
189        ("grant_type", "refresh_token".to_string()),
190        ("refresh_token", refresh_token.to_string()),
191        ("client_id", client_id.to_string()),
192    ];
193    if let Some(secret) = client_secret {
194        form.push(("client_secret", secret));
195    }
196    if let Some(resource) = &token.resource {
197        form.push(("resource", resource.clone()));
198    }
199
200    let client = reqwest::blocking::Client::new();
201    let resp = client
202        .post(token_endpoint)
203        .header("Accept", "application/json")
204        .form(&form)
205        .send()
206        .context("Failed to refresh MCP OAuth token")?;
207    if !resp.status().is_success() {
208        bail!("MCP OAuth refresh failed: HTTP {}", resp.status());
209    }
210    let refreshed: TokenResponse = resp
211        .json()
212        .context("Failed to parse MCP OAuth refresh response")?;
213    let mut new_token = token_from_response(
214        refreshed,
215        token.provider.clone(),
216        token.issuer.clone(),
217        token.resource.clone(),
218        Some(client_id.to_string()),
219        token.client_secret_env.clone(),
220        Some(token_endpoint.to_string()),
221    );
222    if new_token.refresh_token.is_none() {
223        new_token.refresh_token = token.refresh_token.clone();
224    }
225    McpTokenStore::default().save_token(server_name, new_token.clone())?;
226    Ok(new_token)
227}
228
229pub fn login_mcp_oauth(
230    server: &McpServerConfig,
231    opts: McpOAuthLoginOptions,
232) -> Result<McpOAuthToken> {
233    let (url, auth) = match &server.config {
234        McpTransportConfig::Http {
235            url,
236            auth: Some(McpHttpAuthConfig::OAuth(auth)),
237            ..
238        } => (url.as_str(), auth.clone()),
239        McpTransportConfig::Http { .. } => {
240            bail!(
241                "MCP server '{}' is HTTP but does not use OAuth auth",
242                server.name
243            )
244        }
245        McpTransportConfig::Stdio { .. } => {
246            bail!(
247                "MCP server '{}' uses stdio; OAuth login only applies to HTTP MCP servers",
248                server.name
249            )
250        }
251    };
252
253    if auth.provider.as_deref() == Some("github")
254        && auth.issuer.is_none()
255        && auth.resource.is_none()
256        && opts.client_id.is_some()
257    {
258        let client_secret_env = opts.client_secret_env.or(auth.client_secret_env.clone());
259        return login_github_oauth(
260            &server.name,
261            opts.client_id.as_deref().unwrap_or_default(),
262            client_secret_env.as_deref(),
263            if opts.scopes.is_empty() {
264                &auth.scopes
265            } else {
266                &opts.scopes
267            },
268        );
269    }
270
271    let client = reqwest::blocking::Client::new();
272    let discovered = discover_oauth_metadata(&client, url, &auth)?;
273    let (redirect_uri, listener) = bind_callback_listener()?;
274    let state = Uuid::new_v4().to_string();
275    let verifier = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
276    let challenge = base64_url_no_pad(&Sha256::digest(verifier.as_bytes()));
277
278    let client_secret_env = opts.client_secret_env.or(auth.client_secret_env.clone());
279    let client_secret = client_secret_env
280        .as_deref()
281        .and_then(|name| std::env::var(name).ok());
282    let client_id = match opts.client_id.or(auth.client_id.clone()) {
283        Some(id) => id,
284        None => register_oauth_client(&client, &discovered.metadata, &redirect_uri)?.client_id,
285    };
286    let scopes = if !opts.scopes.is_empty() {
287        opts.scopes
288    } else if !auth.scopes.is_empty() {
289        auth.scopes.clone()
290    } else {
291        Vec::new()
292    };
293
294    let mut authorize_url = Url::parse(&discovered.metadata.authorization_endpoint)
295        .context("Invalid OAuth authorization endpoint")?;
296    authorize_url
297        .query_pairs_mut()
298        .append_pair("response_type", "code")
299        .append_pair("client_id", &client_id)
300        .append_pair("redirect_uri", &redirect_uri)
301        .append_pair("state", &state)
302        .append_pair("code_challenge", &challenge)
303        .append_pair("code_challenge_method", "S256");
304    if !scopes.is_empty() {
305        authorize_url
306            .query_pairs_mut()
307            .append_pair("scope", &scopes.join(" "));
308    }
309    if let Some(resource) = &discovered.resource {
310        authorize_url
311            .query_pairs_mut()
312            .append_pair("resource", resource);
313    }
314
315    println!(
316        "  Browser didn't open? Open the URL below to authorize MCP server '{}':",
317        server.name
318    );
319    println!("  {}", authorize_url);
320    let _ = open_browser(authorize_url.as_str());
321
322    let (code, returned_state) = await_oauth_callback(listener)?;
323    if returned_state != state {
324        bail!("OAuth state mismatch");
325    }
326
327    let mut form = vec![
328        ("grant_type", "authorization_code".to_string()),
329        ("code", code),
330        ("client_id", client_id.clone()),
331        ("redirect_uri", redirect_uri),
332        ("code_verifier", verifier),
333    ];
334    if let Some(secret) = client_secret {
335        form.push(("client_secret", secret));
336    }
337    if let Some(resource) = &discovered.resource {
338        form.push(("resource", resource.clone()));
339    }
340
341    let resp = client
342        .post(&discovered.metadata.token_endpoint)
343        .header("Accept", "application/json")
344        .form(&form)
345        .send()
346        .context("Failed to exchange MCP OAuth code")?;
347    if !resp.status().is_success() {
348        bail!("MCP OAuth token exchange failed: HTTP {}", resp.status());
349    }
350    let token: TokenResponse = resp
351        .json()
352        .context("Failed to parse MCP OAuth token response")?;
353    let token = token_from_response(
354        token,
355        auth.provider.unwrap_or_else(|| server.name.clone()),
356        discovered.metadata.issuer,
357        discovered.resource,
358        Some(client_id),
359        client_secret_env,
360        Some(discovered.metadata.token_endpoint),
361    );
362    McpTokenStore::default().save_token(&server.name, token.clone())?;
363    Ok(token)
364}
365
366pub fn login_github_oauth(
367    server_name: &str,
368    client_id: &str,
369    client_secret_env: Option<&str>,
370    scopes: &[String],
371) -> Result<McpOAuthToken> {
372    if client_id.trim().is_empty() {
373        bail!("GitHub OAuth client id is required");
374    }
375    let Some(client_secret_env) = client_secret_env else {
376        bail!(
377            "GitHub MCP OAuth requires --client-secret-env or auth.client_secret_env in mcp.json"
378        );
379    };
380    let client_secret = std::env::var(client_secret_env).with_context(|| {
381        format!(
382            "GitHub MCP OAuth client secret environment variable {} is not set",
383            client_secret_env
384        )
385    })?;
386
387    let (redirect_uri, listener) = bind_callback_listener()?;
388    let state = Uuid::new_v4().to_string();
389    let scope = if scopes.is_empty() {
390        "repo read:org notifications".to_string()
391    } else {
392        scopes.join(" ")
393    };
394
395    let mut url = Url::parse(GITHUB_AUTHORIZE_URL)?;
396    url.query_pairs_mut()
397        .append_pair("client_id", client_id)
398        .append_pair("redirect_uri", &redirect_uri)
399        .append_pair("scope", &scope)
400        .append_pair("state", &state);
401
402    println!("  Browser didn't open? Open the URL below to authorize GitHub MCP:");
403    println!("  {}", url);
404    let _ = open_browser(url.as_str());
405
406    let (code, returned_state) = await_oauth_callback(listener)?;
407    if returned_state != state {
408        bail!("OAuth state mismatch");
409    }
410
411    let client = reqwest::blocking::Client::new();
412    let resp = client
413        .post(GITHUB_TOKEN_URL)
414        .header("Accept", "application/json")
415        .form(&[
416            ("client_id", client_id),
417            ("client_secret", &client_secret),
418            ("code", &code),
419            ("redirect_uri", &redirect_uri),
420        ])
421        .send()
422        .context("Failed to exchange GitHub OAuth code")?;
423    if !resp.status().is_success() {
424        bail!("GitHub OAuth token exchange failed: HTTP {}", resp.status());
425    }
426    let token: TokenResponse = resp
427        .json()
428        .context("Failed to parse GitHub OAuth token response")?;
429    let token = token_from_response(
430        token,
431        "github".to_string(),
432        Some("https://github.com".to_string()),
433        Some(GITHUB_MCP_RESOURCE.to_string()),
434        Some(client_id.to_string()),
435        Some(client_secret_env.to_string()),
436        Some(GITHUB_TOKEN_URL.to_string()),
437    );
438    McpTokenStore::default().save_token(server_name, token.clone())?;
439    Ok(token)
440}
441
442struct DiscoveredOAuth {
443    metadata: AuthorizationServerMetadata,
444    resource: Option<String>,
445}
446
447fn discover_oauth_metadata(
448    client: &reqwest::blocking::Client,
449    mcp_url: &str,
450    auth: &McpOAuthConfig,
451) -> Result<DiscoveredOAuth> {
452    if let Some(issuer) = &auth.issuer {
453        let metadata = fetch_authorization_server_metadata(client, issuer)?;
454        return Ok(DiscoveredOAuth {
455            metadata,
456            resource: auth.resource.clone().or_else(|| Some(mcp_url.to_string())),
457        });
458    }
459
460    let resource_metadata_url = discover_resource_metadata_url(client, mcp_url, auth)?;
461    let prm: ProtectedResourceMetadata = client
462        .get(&resource_metadata_url)
463        .header("Accept", "application/json")
464        .send()
465        .with_context(|| {
466            format!("Failed to fetch MCP OAuth resource metadata from {resource_metadata_url}")
467        })?
468        .error_for_status()
469        .with_context(|| {
470            format!("MCP OAuth resource metadata request failed for {resource_metadata_url}")
471        })?
472        .json()
473        .with_context(|| {
474            format!("Failed to parse MCP OAuth resource metadata from {resource_metadata_url}")
475        })?;
476    let auth_server = prm.authorization_servers.first().ok_or_else(|| {
477        anyhow::anyhow!("MCP OAuth resource metadata has no authorization_servers")
478    })?;
479    let metadata = fetch_authorization_server_metadata(client, auth_server)?;
480    Ok(DiscoveredOAuth {
481        metadata,
482        resource: auth
483            .resource
484            .clone()
485            .or(prm.resource)
486            .or_else(|| Some(mcp_url.to_string())),
487    })
488}
489
490fn discover_resource_metadata_url(
491    client: &reqwest::blocking::Client,
492    mcp_url: &str,
493    auth: &McpOAuthConfig,
494) -> Result<String> {
495    if let Some(resource) = &auth.resource {
496        if resource.contains("/.well-known/") {
497            return Ok(resource.clone());
498        }
499    }
500
501    let probe = json!({
502        "jsonrpc": "2.0",
503        "id": 1,
504        "method": "initialize",
505        "params": {
506            "protocolVersion": "2024-11-05",
507            "capabilities": { "tools": {} },
508            "clientInfo": { "name": "atomcode", "version": env!("CARGO_PKG_VERSION") }
509        }
510    });
511    if let Ok(resp) = client
512        .post(mcp_url)
513        .header("Accept", "application/json, text/event-stream")
514        .json(&probe)
515        .send()
516    {
517        if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
518            if let Some(header) = resp
519                .headers()
520                .get(reqwest::header::WWW_AUTHENTICATE)
521                .and_then(|v| v.to_str().ok())
522            {
523                if let Some(url) = parse_www_authenticate_resource_metadata(header) {
524                    return Ok(url);
525                }
526            }
527        }
528    }
529
530    let parsed = Url::parse(mcp_url).context("Invalid MCP server URL")?;
531    let origin = parsed.origin().ascii_serialization();
532    Ok(format!("{}/.well-known/oauth-protected-resource", origin))
533}
534
535pub fn parse_www_authenticate_resource_metadata(header: &str) -> Option<String> {
536    for part in header.split(',') {
537        let part = part.trim().strip_prefix("Bearer ").unwrap_or(part.trim());
538        let Some((key, value)) = part.split_once('=') else {
539            continue;
540        };
541        if key.trim().eq_ignore_ascii_case("resource_metadata") {
542            return Some(value.trim().trim_matches('"').to_string());
543        }
544    }
545    None
546}
547
548fn fetch_authorization_server_metadata(
549    client: &reqwest::blocking::Client,
550    issuer: &str,
551) -> Result<AuthorizationServerMetadata> {
552    if issuer.contains("/.well-known/") {
553        return fetch_metadata_url(client, issuer);
554    }
555    let issuer = issuer.trim_end_matches('/');
556    let candidates = [
557        format!("{issuer}/.well-known/oauth-authorization-server"),
558        format!("{issuer}/.well-known/openid-configuration"),
559    ];
560    let mut last_err = None;
561    for candidate in candidates {
562        match fetch_metadata_url(client, &candidate) {
563            Ok(metadata) => return Ok(metadata),
564            Err(e) => last_err = Some(e),
565        }
566    }
567    Err(last_err.unwrap_or_else(|| anyhow::anyhow!("No OAuth metadata URL candidates")))
568}
569
570fn fetch_metadata_url(
571    client: &reqwest::blocking::Client,
572    url: &str,
573) -> Result<AuthorizationServerMetadata> {
574    client
575        .get(url)
576        .header("Accept", "application/json")
577        .send()
578        .with_context(|| format!("Failed to fetch OAuth authorization server metadata from {url}"))?
579        .error_for_status()
580        .with_context(|| format!("OAuth authorization server metadata request failed for {url}"))?
581        .json()
582        .with_context(|| format!("Failed to parse OAuth authorization server metadata from {url}"))
583}
584
585fn register_oauth_client(
586    client: &reqwest::blocking::Client,
587    metadata: &AuthorizationServerMetadata,
588    redirect_uri: &str,
589) -> Result<ClientRegistrationResponse> {
590    let Some(registration_endpoint) = metadata.registration_endpoint.as_deref() else {
591        bail!("MCP OAuth requires client_id because the authorization server does not advertise dynamic client registration");
592    };
593    let resp = client
594        .post(registration_endpoint)
595        .header("Accept", "application/json")
596        .json(&json!({
597            "client_name": "AtomCode",
598            "redirect_uris": [redirect_uri],
599            "grant_types": ["authorization_code", "refresh_token"],
600            "response_types": ["code"],
601            "token_endpoint_auth_method": "none"
602        }))
603        .send()
604        .context("Failed to dynamically register MCP OAuth client")?;
605    if !resp.status().is_success() {
606        bail!(
607            "MCP OAuth dynamic client registration failed: HTTP {}",
608            resp.status()
609        );
610    }
611    resp.json()
612        .context("Failed to parse MCP OAuth dynamic client registration response")
613}
614
615fn token_from_response(
616    token: TokenResponse,
617    provider: String,
618    issuer: Option<String>,
619    resource: Option<String>,
620    client_id: Option<String>,
621    client_secret_env: Option<String>,
622    token_endpoint: Option<String>,
623) -> McpOAuthToken {
624    McpOAuthToken {
625        provider,
626        issuer,
627        token_type: token.token_type.unwrap_or_else(default_token_type),
628        access_token: token.access_token,
629        refresh_token: token.refresh_token,
630        expires_at: token.expires_in.map(|seconds| now_unix() + seconds),
631        scopes: token.scope.split_whitespace().map(str::to_string).collect(),
632        resource,
633        client_id,
634        client_secret_env,
635        token_endpoint,
636    }
637}
638
639fn bind_callback_listener() -> Result<(String, TcpListener)> {
640    let listener = TcpListener::bind(("127.0.0.1", 0))
641        .context("Failed to bind local OAuth callback listener")?;
642    let port = listener.local_addr()?.port();
643    Ok((format!("http://127.0.0.1:{}/callback", port), listener))
644}
645
646fn await_oauth_callback(listener: TcpListener) -> Result<(String, String)> {
647    let (mut stream, _) = listener
648        .accept()
649        .context("Failed to accept OAuth callback")?;
650    let mut buf = [0_u8; 4096];
651    let n = stream
652        .read(&mut buf)
653        .context("Failed to read OAuth callback")?;
654    let req = String::from_utf8_lossy(&buf[..n]);
655    let path = req
656        .lines()
657        .next()
658        .and_then(|line| line.split_whitespace().nth(1))
659        .ok_or_else(|| anyhow::anyhow!("Invalid OAuth callback request"))?;
660    let url =
661        Url::parse(&format!("http://127.0.0.1{}", path)).context("Invalid OAuth callback URL")?;
662    let code = url
663        .query_pairs()
664        .find(|(k, _)| k == "code")
665        .map(|(_, v)| v.into_owned())
666        .ok_or_else(|| anyhow::anyhow!("OAuth callback did not include code"))?;
667    let state = url
668        .query_pairs()
669        .find(|(k, _)| k == "state")
670        .map(|(_, v)| v.into_owned())
671        .ok_or_else(|| anyhow::anyhow!("OAuth callback did not include state"))?;
672
673    let body = "Authorization complete. You can close this tab.";
674    let response = format!(
675        "HTTP/1.1 200 OK\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
676        body.len(), body
677    );
678    let _ = stream.write_all(response.as_bytes());
679    Ok((code, state))
680}
681
682fn now_unix() -> i64 {
683    SystemTime::now()
684        .duration_since(UNIX_EPOCH)
685        .unwrap_or_default()
686        .as_secs() as i64
687}
688
689fn default_token_type() -> String {
690    "Bearer".to_string()
691}
692
693fn base64_url_no_pad(bytes: &[u8]) -> String {
694    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
695    let mut out = String::new();
696    let mut i = 0;
697    while i + 3 <= bytes.len() {
698        let n = ((bytes[i] as u32) << 16) | ((bytes[i + 1] as u32) << 8) | bytes[i + 2] as u32;
699        out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
700        out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
701        out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char);
702        out.push(ALPHABET[(n & 0x3f) as usize] as char);
703        i += 3;
704    }
705    match bytes.len() - i {
706        1 => {
707            let n = (bytes[i] as u32) << 16;
708            out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
709            out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
710        }
711        2 => {
712            let n = ((bytes[i] as u32) << 16) | ((bytes[i + 1] as u32) << 8);
713            out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
714            out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
715            out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char);
716        }
717        _ => {}
718    }
719    out
720}
721
722#[cfg(target_os = "macos")]
723fn open_browser(url: &str) -> Result<()> {
724    std::process::Command::new("open").arg(url).spawn()?;
725    Ok(())
726}
727
728#[cfg(target_os = "linux")]
729fn open_browser(url: &str) -> Result<()> {
730    std::process::Command::new("xdg-open").arg(url).spawn()?;
731    Ok(())
732}
733
734#[cfg(target_os = "windows")]
735fn open_browser(url: &str) -> Result<()> {
736    use std::os::windows::process::CommandExt;
737    std::process::Command::new("cmd")
738        .raw_arg(format!("/C start \"\" \"{}\"", url))
739        .spawn()?;
740    Ok(())
741}
742
743#[cfg(test)]
744mod tests {
745    use super::{
746        base64_url_no_pad, parse_www_authenticate_resource_metadata, McpOAuthToken, McpTokenStore,
747    };
748
749    #[test]
750    fn base64_url_omits_padding() {
751        assert_eq!(base64_url_no_pad(b"abc"), "YWJj");
752        assert_eq!(base64_url_no_pad(b"ab"), "YWI");
753        assert_eq!(base64_url_no_pad(b"a"), "YQ");
754    }
755
756    #[test]
757    fn www_authenticate_resource_metadata_is_parsed() {
758        let header = r#"Bearer realm="mcp", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#;
759        assert_eq!(
760            parse_www_authenticate_resource_metadata(header).as_deref(),
761            Some("https://mcp.example.com/.well-known/oauth-protected-resource")
762        );
763    }
764
765    #[test]
766    fn token_store_round_trips_server_token() {
767        let dir = tempfile::tempdir().unwrap();
768        let store = McpTokenStore::new(dir.path().join("mcp_auth.toml"));
769        let token = McpOAuthToken {
770            provider: "github".to_string(),
771            issuer: Some("https://github.com".to_string()),
772            token_type: "Bearer".to_string(),
773            access_token: "token".to_string(),
774            refresh_token: None,
775            expires_at: None,
776            scopes: vec!["repo".to_string()],
777            resource: Some("https://api.githubcopilot.com/mcp/".to_string()),
778            client_id: Some("client".to_string()),
779            client_secret_env: None,
780            token_endpoint: Some("https://github.com/login/oauth/access_token".to_string()),
781        };
782        store.save_token("github", token).unwrap();
783
784        let loaded = store.load_token("github").unwrap().unwrap();
785        assert_eq!(loaded.provider, "github");
786        assert_eq!(loaded.access_token, "token");
787        assert_eq!(loaded.scopes, vec!["repo"]);
788        assert!(store.delete_token("github").unwrap());
789        assert!(store.load_token("github").unwrap().is_none());
790    }
791}