1use 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
163pub 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}