1use std::path::Path;
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use base64::Engine;
12use serde::{Deserialize, Serialize};
13use tokio::sync::Mutex;
14use toml_edit::{Array, DocumentMut, Item, Table, value};
15use tower_mcp::client::{
16 OAuthAuthorizationAction, OAuthAuthorizationFlow, OAuthAuthorizationHandler,
17 OAuthAuthorizationRequest, OAuthClientError, OAuthClientRegistration,
18 OAuthClientRegistrationOptions, OAuthClientRegistrationStore, OAuthDynamicClientRegistration,
19 OAuthRedirectPolicy, OAuthStoredToken, OAuthTokenBinding, OAuthTokenStore,
20};
21
22use crate::config::OAuthProfile;
23
24const KEYRING_SERVICE: &str = "mcp-repl/oauth";
25const KEYRING_CHUNK_BYTES: usize = 768;
29const MAX_KEYRING_CHUNKS: usize = 4096;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32struct KeyringManifest {
33 generation: String,
34 chunks: usize,
35}
36
37impl KeyringManifest {
38 fn parse(encoded: &str) -> Result<Self, String> {
39 let mut parts = encoded.split(':');
40 let (Some("v1"), Some(generation), Some(chunks), None) =
41 (parts.next(), parts.next(), parts.next(), parts.next())
42 else {
43 return Err("stored OAuth credential manifest is invalid".to_string());
44 };
45 if generation.is_empty()
46 || !generation
47 .chars()
48 .all(|character| character.is_ascii_hexdigit())
49 {
50 return Err("stored OAuth credential manifest has an invalid generation".to_string());
51 }
52 let chunks = chunks
53 .parse::<usize>()
54 .map_err(|_| "stored OAuth credential manifest has an invalid chunk count")?;
55 if chunks == 0 || chunks > MAX_KEYRING_CHUNKS {
56 return Err("stored OAuth credential manifest has an invalid chunk count".to_string());
57 }
58 Ok(Self {
59 generation: generation.to_string(),
60 chunks,
61 })
62 }
63
64 fn encode(&self) -> String {
65 format!("v1:{}:{}", self.generation, self.chunks)
66 }
67}
68
69fn new_generation() -> Result<String, String> {
70 let mut bytes = [0_u8; 16];
71 getrandom::fill(&mut bytes)
72 .map_err(|error| format!("cannot generate credential-store record id: {error}"))?;
73 Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
74}
75
76fn chunk_account(profile: &str, manifest: &KeyringManifest, index: usize) -> String {
77 format!("{profile}:{}:{index}", manifest.generation)
78}
79
80fn encode_chunks(secret: &str) -> Vec<String> {
81 secret
82 .as_bytes()
83 .chunks(KEYRING_CHUNK_BYTES)
84 .map(|chunk| base64::engine::general_purpose::STANDARD.encode(chunk))
85 .collect()
86}
87
88fn decode_chunks(chunks: impl IntoIterator<Item = String>) -> Result<String, String> {
89 let mut decoded = Vec::new();
90 for chunk in chunks {
91 decoded.extend(
92 base64::engine::general_purpose::STANDARD
93 .decode(chunk)
94 .map_err(|_| "stored OAuth credential chunk is invalid")?,
95 );
96 }
97 String::from_utf8(decoded)
98 .map_err(|_| "stored OAuth credential record is not UTF-8".to_string())
99}
100
101fn keyring_entry(account: &str) -> Result<keyring::v1::Entry, String> {
102 keyring::v1::Entry::new(KEYRING_SERVICE, account).map_err(|error| error.to_string())
103}
104
105fn delete_keyring_entry(account: &str) -> Result<(), String> {
106 match keyring_entry(account)?.delete_credential() {
107 Ok(()) | Err(keyring::v1::Error::NoEntry) => Ok(()),
108 Err(error) => Err(error.to_string()),
109 }
110}
111
112fn read_keyring_manifest(profile: &str) -> Result<Option<KeyringManifest>, String> {
113 match keyring_entry(profile)?.get_password() {
114 Ok(encoded) => KeyringManifest::parse(&encoded).map(Some),
115 Err(keyring::v1::Error::NoEntry) => Ok(None),
116 Err(error) => Err(error.to_string()),
117 }
118}
119
120fn delete_keyring_generation(profile: &str, manifest: &KeyringManifest) -> Result<(), String> {
121 let mut first_error = None;
122 for index in 0..manifest.chunks {
123 if let Err(error) = delete_keyring_entry(&chunk_account(profile, manifest, index))
124 && first_error.is_none()
125 {
126 first_error = Some(error);
127 }
128 }
129 first_error.map_or(Ok(()), Err)
130}
131
132#[async_trait]
133trait SecretBackend: Send + Sync {
134 async fn load(&self, profile: &str) -> Result<Option<String>, String>;
135 async fn save(&self, profile: &str, secret: &str) -> Result<(), String>;
136 async fn remove(&self, profile: &str) -> Result<(), String>;
137}
138
139#[derive(Debug, Default)]
140struct KeyringSecretBackend;
141
142impl KeyringSecretBackend {
143 fn check() -> Result<(), String> {
144 keyring::v1::Entry::store_status()
145 .as_ref()
146 .map_err(|error| {
147 format!(
148 "the operating-system credential store is unavailable: {error}. \
149 mcp-repl will not fall back to plaintext; use MCP_BEARER for a headless \
150 environment or configure the platform credential service"
151 )
152 })
153 .copied()
154 }
155}
156
157#[async_trait]
158impl SecretBackend for KeyringSecretBackend {
159 async fn load(&self, profile: &str) -> Result<Option<String>, String> {
160 let profile = profile.to_string();
161 tokio::task::spawn_blocking(move || {
162 let Some(manifest) = read_keyring_manifest(&profile)? else {
163 return Ok(None);
164 };
165 let mut chunks = Vec::with_capacity(manifest.chunks);
166 for index in 0..manifest.chunks {
167 let account = chunk_account(&profile, &manifest, index);
168 let chunk = keyring_entry(&account)?.get_password().map_err(|error| {
169 if matches!(error, keyring::v1::Error::NoEntry) {
170 "stored OAuth credential record is incomplete".to_string()
171 } else {
172 error.to_string()
173 }
174 })?;
175 chunks.push(chunk);
176 }
177 decode_chunks(chunks).map(Some)
178 })
179 .await
180 .map_err(|error| format!("credential-store worker failed: {error}"))?
181 }
182
183 async fn save(&self, profile: &str, secret: &str) -> Result<(), String> {
184 let profile = profile.to_string();
185 let secret = secret.to_string();
186 tokio::task::spawn_blocking(move || {
187 let previous = read_keyring_manifest(&profile)?;
188 let encoded_chunks = encode_chunks(&secret);
189 if encoded_chunks.is_empty() || encoded_chunks.len() > MAX_KEYRING_CHUNKS {
190 return Err("OAuth credential record is too large for the secure store".to_string());
191 }
192 let manifest = KeyringManifest {
193 generation: new_generation()?,
194 chunks: encoded_chunks.len(),
195 };
196 for (index, chunk) in encoded_chunks.iter().enumerate() {
197 let account = chunk_account(&profile, &manifest, index);
198 if let Err(error) = keyring_entry(&account)
199 .and_then(|entry| entry.set_password(chunk).map_err(|error| error.to_string()))
200 {
201 for cleanup in 0..index {
202 let _ = delete_keyring_entry(&chunk_account(&profile, &manifest, cleanup));
203 }
204 return Err(error);
205 }
206 }
207 if let Err(error) = keyring_entry(&profile).and_then(|entry| {
208 entry
209 .set_password(&manifest.encode())
210 .map_err(|error| error.to_string())
211 }) {
212 let _ = delete_keyring_generation(&profile, &manifest);
213 return Err(error);
214 }
215 if let Some(previous) = previous {
216 delete_keyring_generation(&profile, &previous)?;
217 }
218 Ok(())
219 })
220 .await
221 .map_err(|error| format!("credential-store worker failed: {error}"))?
222 }
223
224 async fn remove(&self, profile: &str) -> Result<(), String> {
225 let profile = profile.to_string();
226 tokio::task::spawn_blocking(move || {
227 if let Some(manifest) = read_keyring_manifest(&profile)? {
228 delete_keyring_generation(&profile, &manifest)?;
229 }
230 delete_keyring_entry(&profile)
231 })
232 .await
233 .map_err(|error| format!("credential-store worker failed: {error}"))?
234 }
235}
236
237#[derive(Clone, Serialize, Deserialize)]
238struct StoredToken {
239 binding: OAuthTokenBinding,
240 token: OAuthStoredToken,
241}
242
243#[derive(Clone, Serialize, Deserialize)]
244struct StoredRegistration {
245 issuer: String,
246 registration: OAuthClientRegistration,
247}
248
249#[derive(Default, Serialize, Deserialize)]
250struct Secrets {
251 #[serde(default)]
252 tokens: Vec<StoredToken>,
253 #[serde(default)]
254 registrations: Vec<StoredRegistration>,
255}
256
257#[derive(Clone)]
260pub struct CredentialStore {
261 profile: Arc<str>,
262 backend: Arc<dyn SecretBackend>,
263 lock: Arc<Mutex<()>>,
264}
265
266impl std::fmt::Debug for CredentialStore {
267 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268 formatter
269 .debug_struct("CredentialStore")
270 .field("profile", &self.profile)
271 .finish_non_exhaustive()
272 }
273}
274
275impl CredentialStore {
276 pub fn keyring(profile: &str) -> Result<Self, String> {
277 validate_name(profile)?;
278 KeyringSecretBackend::check()?;
279 Ok(Self::with_backend(profile, Arc::new(KeyringSecretBackend)))
280 }
281
282 fn with_backend(profile: &str, backend: Arc<dyn SecretBackend>) -> Self {
283 Self {
284 profile: Arc::from(profile),
285 backend,
286 lock: Arc::new(Mutex::new(())),
287 }
288 }
289
290 async fn load_secrets(&self) -> Result<Secrets, String> {
291 let Some(encoded) = self.backend.load(&self.profile).await? else {
292 return Ok(Secrets::default());
293 };
294 serde_json::from_str(&encoded)
295 .map_err(|error| format!("stored OAuth profile is invalid: {error}"))
296 }
297
298 async fn save_secrets(&self, secrets: &Secrets) -> Result<(), String> {
299 if secrets.tokens.is_empty() && secrets.registrations.is_empty() {
300 return self.backend.remove(&self.profile).await;
301 }
302 let encoded = serde_json::to_string(secrets)
303 .map_err(|error| format!("cannot encode OAuth credentials: {error}"))?;
304 self.backend.save(&self.profile, &encoded).await
305 }
306
307 pub async fn clear(&self) -> Result<(), String> {
308 let _guard = self.lock.lock().await;
309 self.backend.remove(&self.profile).await
310 }
311
312 pub async fn clear_tokens(&self) -> Result<(), String> {
313 let _guard = self.lock.lock().await;
314 let mut secrets = self.load_secrets().await?;
315 secrets.tokens.clear();
316 self.save_secrets(&secrets).await
317 }
318
319 pub async fn has_tokens(&self) -> Result<bool, String> {
320 let _guard = self.lock.lock().await;
321 self.load_secrets()
322 .await
323 .map(|secrets| !secrets.tokens.is_empty())
324 }
325}
326
327#[async_trait]
328impl OAuthTokenStore for CredentialStore {
329 async fn load(
330 &self,
331 binding: &OAuthTokenBinding,
332 ) -> Result<Option<OAuthStoredToken>, OAuthClientError> {
333 let _guard = self.lock.lock().await;
334 self.load_secrets()
335 .await
336 .map(|secrets| {
337 secrets
338 .tokens
339 .into_iter()
340 .find(|stored| stored.binding == *binding)
341 .map(|stored| stored.token)
342 })
343 .map_err(OAuthClientError::TokenStore)
344 }
345
346 async fn save(
347 &self,
348 binding: &OAuthTokenBinding,
349 token: &OAuthStoredToken,
350 ) -> Result<(), OAuthClientError> {
351 let _guard = self.lock.lock().await;
352 let mut secrets = self
353 .load_secrets()
354 .await
355 .map_err(OAuthClientError::TokenStore)?;
356 secrets.tokens.retain(|stored| stored.binding != *binding);
357 secrets.tokens.push(StoredToken {
358 binding: binding.clone(),
359 token: token.clone(),
360 });
361 self.save_secrets(&secrets)
362 .await
363 .map_err(OAuthClientError::TokenStore)
364 }
365
366 async fn remove(&self, binding: &OAuthTokenBinding) -> Result<(), OAuthClientError> {
367 let _guard = self.lock.lock().await;
368 let mut secrets = self
369 .load_secrets()
370 .await
371 .map_err(OAuthClientError::TokenStore)?;
372 secrets.tokens.retain(|stored| stored.binding != *binding);
373 self.save_secrets(&secrets)
374 .await
375 .map_err(OAuthClientError::TokenStore)
376 }
377}
378
379#[async_trait]
380impl OAuthClientRegistrationStore for CredentialStore {
381 async fn load(
382 &self,
383 issuer: &str,
384 ) -> Result<Option<OAuthClientRegistration>, OAuthClientError> {
385 let _guard = self.lock.lock().await;
386 self.load_secrets()
387 .await
388 .map(|secrets| {
389 secrets
390 .registrations
391 .into_iter()
392 .find(|stored| stored.issuer == issuer)
393 .map(|stored| stored.registration)
394 })
395 .map_err(OAuthClientError::CredentialStore)
396 }
397
398 async fn save(
399 &self,
400 issuer: &str,
401 registration: &OAuthClientRegistration,
402 ) -> Result<(), OAuthClientError> {
403 let _guard = self.lock.lock().await;
404 let mut secrets = self
405 .load_secrets()
406 .await
407 .map_err(OAuthClientError::CredentialStore)?;
408 secrets
409 .registrations
410 .retain(|stored| stored.issuer != issuer);
411 secrets.registrations.push(StoredRegistration {
412 issuer: issuer.to_string(),
413 registration: registration.clone(),
414 });
415 self.save_secrets(&secrets)
416 .await
417 .map_err(OAuthClientError::CredentialStore)
418 }
419
420 async fn remove(&self, issuer: &str) -> Result<(), OAuthClientError> {
421 let _guard = self.lock.lock().await;
422 let mut secrets = self
423 .load_secrets()
424 .await
425 .map_err(OAuthClientError::CredentialStore)?;
426 secrets
427 .registrations
428 .retain(|stored| stored.issuer != issuer);
429 self.save_secrets(&secrets)
430 .await
431 .map_err(OAuthClientError::CredentialStore)
432 }
433}
434
435type BrowserOpener = dyn Fn(&str) -> Result<(), String> + Send + Sync;
436
437#[derive(Clone)]
438struct BrowserAuthorizationHandler {
439 profile: Arc<str>,
440 interactive: bool,
441 open_browser: bool,
442 opener: Arc<BrowserOpener>,
443}
444
445impl BrowserAuthorizationHandler {
446 fn new(profile: &str, interactive: bool, open_browser: bool) -> Self {
447 Self {
448 profile: Arc::from(profile),
449 interactive,
450 open_browser,
451 opener: Arc::new(|url| webbrowser::open(url).map_err(|error| error.to_string())),
452 }
453 }
454}
455
456#[async_trait]
457impl OAuthAuthorizationHandler for BrowserAuthorizationHandler {
458 async fn authorize(
459 &self,
460 request: OAuthAuthorizationRequest,
461 ) -> Result<OAuthAuthorizationAction, OAuthClientError> {
462 if !self.interactive {
463 return Err(OAuthClientError::Redirect(format!(
464 "interactive authorization is required; run `mcp-repl --login {} --http {}` first",
465 self.profile, request.resource
466 )));
467 }
468 if self.open_browser {
469 match (self.opener)(&request.authorization_url) {
470 Ok(()) => eprintln!("OAuth authorization opened in your browser."),
471 Err(error) => eprintln!("Could not open a browser ({error})."),
472 }
473 }
474 eprintln!(
475 "Authorize this client, then return here (waiting up to 5 minutes):\n{}",
476 request.authorization_url
477 );
478 Ok(OAuthAuthorizationAction::AwaitLoopback)
479 }
480}
481
482pub fn build_flow(
483 name: &str,
484 resource_url: &str,
485 metadata: &OAuthProfile,
486 interactive: bool,
487 open_browser: bool,
488) -> Result<(OAuthAuthorizationFlow, CredentialStore), String> {
489 let store = CredentialStore::keyring(name)?;
490 let mut options = OAuthClientRegistrationOptions::new().with_dynamic_registration(
491 OAuthDynamicClientRegistration::native("mcp-repl", std::iter::empty::<String>()),
492 );
493 if let Some(client_id) = &metadata.client_id_metadata_document {
494 options = options.with_client_id_metadata_document(client_id.clone());
495 }
496 let mut builder = OAuthAuthorizationFlow::builder(resource_url)
497 .redirect_policy(OAuthRedirectPolicy::loopback())
498 .registration_options(options)
499 .registration_store(store.clone())
500 .token_store(store.clone())
501 .authorization_handler(BrowserAuthorizationHandler::new(
502 name,
503 interactive,
504 open_browser,
505 ));
506 if let Some(issuer) = &metadata.authorization_server {
507 builder = builder.preferred_authorization_server(issuer.clone());
508 }
509 let flow = builder.build().map_err(|error| error.to_string())?;
510 Ok((flow, store))
511}
512
513pub fn validate_name(name: &str) -> Result<(), String> {
514 if name.is_empty()
515 || name.len() > 64
516 || !name
517 .chars()
518 .all(|character| character.is_ascii_alphanumeric() || "._-".contains(character))
519 {
520 return Err(
521 "OAuth profile names must be 1-64 characters containing only ASCII letters, digits, \
522 `.`, `_`, or `-`"
523 .to_string(),
524 );
525 }
526 Ok(())
527}
528
529pub fn save_metadata(path: &Path, name: &str, profile: &OAuthProfile) -> Result<(), String> {
530 validate_name(name)?;
531 edit_config(path, |document| {
532 if document.get("oauth").is_none() {
533 document["oauth"] = Item::Table(Table::new());
534 }
535 let oauth = document["oauth"]
536 .as_table_mut()
537 .ok_or("top-level `oauth` must be a table")?;
538 let mut table = Table::new();
539 table["url"] = value(&profile.url);
540 if !profile.scopes.is_empty() {
541 let mut scopes = Array::new();
542 for scope in &profile.scopes {
543 scopes.push(scope.as_str());
544 }
545 table["scopes"] = value(scopes);
546 }
547 if let Some(client_id) = &profile.client_id_metadata_document {
548 table["client_id_metadata_document"] = value(client_id);
549 }
550 if let Some(issuer) = &profile.authorization_server {
551 table["authorization_server"] = value(issuer);
552 }
553 oauth[name] = Item::Table(table);
554 Ok(())
555 })
556}
557
558pub fn remove_metadata(path: &Path, name: &str) -> Result<bool, String> {
559 validate_name(name)?;
560 let mut removed = false;
561 edit_config(path, |document| {
562 let Some(oauth) = document.get_mut("oauth") else {
563 return Ok(());
564 };
565 let table = oauth
566 .as_table_mut()
567 .ok_or("top-level `oauth` must be a table")?;
568 removed = table.remove(name).is_some();
569 if table.is_empty() {
570 document.remove("oauth");
571 }
572 Ok(())
573 })?;
574 Ok(removed)
575}
576
577fn edit_config(
578 path: &Path,
579 edit: impl FnOnce(&mut DocumentMut) -> Result<(), String>,
580) -> Result<(), String> {
581 let source = match std::fs::read_to_string(path) {
582 Ok(source) => source,
583 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
584 Err(error) => return Err(format!("{}: {error}", path.display())),
585 };
586 let mut document = source
587 .parse::<DocumentMut>()
588 .map_err(|error| format!("{}: {error}", path.display()))?;
589 edit(&mut document)?;
590 write_atomic(path, &document.to_string())
591 .map_err(|error| format!("{}: {error}", path.display()))
592}
593
594use crate::secure_file::write_atomic;
595
596#[cfg(test)]
597mod tests {
598 use std::collections::HashMap;
599 use std::sync::RwLock;
600 use std::sync::atomic::{AtomicUsize, Ordering};
601
602 use super::*;
603
604 #[derive(Default)]
605 struct MemoryBackend(RwLock<HashMap<String, String>>);
606
607 #[async_trait]
608 impl SecretBackend for MemoryBackend {
609 async fn load(&self, profile: &str) -> Result<Option<String>, String> {
610 Ok(self.0.read().unwrap().get(profile).cloned())
611 }
612
613 async fn save(&self, profile: &str, secret: &str) -> Result<(), String> {
614 self.0
615 .write()
616 .unwrap()
617 .insert(profile.to_string(), secret.to_string());
618 Ok(())
619 }
620
621 async fn remove(&self, profile: &str) -> Result<(), String> {
622 self.0.write().unwrap().remove(profile);
623 Ok(())
624 }
625 }
626
627 fn binding(resource: &str) -> OAuthTokenBinding {
628 OAuthTokenBinding {
629 resource: resource.to_string(),
630 issuer: "https://auth.example".to_string(),
631 client_id: "client".to_string(),
632 }
633 }
634
635 #[test]
636 fn secure_store_chunks_large_unicode_records_below_platform_limit() {
637 let secret = format!("{}{}", "jwt.".repeat(1200), "🔐".repeat(700));
638 let chunks = encode_chunks(&secret);
639 assert!(chunks.len() > 1);
640 assert!(chunks.iter().all(|chunk| chunk.len() <= 1024));
641 assert_eq!(decode_chunks(chunks).unwrap(), secret);
642
643 let manifest = KeyringManifest {
644 generation: "01abcdef".to_string(),
645 chunks: 7,
646 };
647 assert_eq!(
648 KeyringManifest::parse(&manifest.encode()).unwrap(),
649 manifest
650 );
651 assert!(KeyringManifest::parse("v1:bad:not-a-number").is_err());
652 }
653
654 #[test]
655 fn profile_names_are_bounded_for_platform_stores() {
656 assert!(validate_name("work-prod_1.example").is_ok());
657 assert!(validate_name(&"a".repeat(65)).is_err());
658 assert!(validate_name("contains/slash").is_err());
659 }
660
661 fn token(access_token: &str) -> OAuthStoredToken {
662 OAuthStoredToken {
663 access_token: access_token.to_string(),
664 refresh_token: Some("refresh-secret".to_string()),
665 expires_at: u64::MAX,
666 scopes: vec!["openid".to_string()],
667 }
668 }
669
670 #[tokio::test]
671 async fn exact_bindings_and_issuers_share_one_secret_record() {
672 let backend = Arc::new(MemoryBackend::default());
673 let store = CredentialStore::with_backend("work", backend.clone());
674 OAuthTokenStore::save(&store, &binding("https://one/mcp"), &token("one"))
675 .await
676 .unwrap();
677 OAuthTokenStore::save(&store, &binding("https://two/mcp"), &token("two"))
678 .await
679 .unwrap();
680 let registration = OAuthClientRegistration::dynamically_registered(
681 "https://auth.example",
682 "client",
683 Some("client-secret".to_string()),
684 );
685 OAuthClientRegistrationStore::save(&store, "https://auth.example", ®istration)
686 .await
687 .unwrap();
688
689 assert_eq!(
690 OAuthTokenStore::load(&store, &binding("https://two/mcp"))
691 .await
692 .unwrap()
693 .unwrap()
694 .access_token,
695 "two"
696 );
697 assert!(
698 OAuthTokenStore::load(&store, &binding("https://other/mcp"))
699 .await
700 .unwrap()
701 .is_none()
702 );
703 assert_eq!(
704 OAuthClientRegistrationStore::load(&store, "https://auth.example")
705 .await
706 .unwrap()
707 .unwrap(),
708 registration
709 );
710
711 let encoded = backend.0.read().unwrap()["work"].clone();
712 assert!(encoded.contains("refresh-secret"));
713 assert!(encoded.contains("client-secret"));
714 assert!(!format!("{store:?}").contains("secret"));
715 }
716
717 #[tokio::test]
718 async fn clear_removes_every_secret_for_profile() {
719 let backend = Arc::new(MemoryBackend::default());
720 let store = CredentialStore::with_backend("work", backend.clone());
721 OAuthTokenStore::save(&store, &binding("https://one/mcp"), &token("one"))
722 .await
723 .unwrap();
724 store.clear().await.unwrap();
725 assert!(backend.0.read().unwrap().is_empty());
726 }
727
728 #[tokio::test]
729 async fn clearing_tokens_preserves_dynamic_registration() {
730 let backend = Arc::new(MemoryBackend::default());
731 let store = CredentialStore::with_backend("work", backend);
732 OAuthTokenStore::save(&store, &binding("https://one/mcp"), &token("one"))
733 .await
734 .unwrap();
735 let registration = OAuthClientRegistration::dynamically_registered(
736 "https://auth.example",
737 "client",
738 Some("client-secret".to_string()),
739 );
740 OAuthClientRegistrationStore::save(&store, "https://auth.example", ®istration)
741 .await
742 .unwrap();
743
744 assert!(store.has_tokens().await.unwrap());
745 store.clear_tokens().await.unwrap();
746 assert!(!store.has_tokens().await.unwrap());
747 assert!(
748 OAuthTokenStore::load(&store, &binding("https://one/mcp"))
749 .await
750 .unwrap()
751 .is_none()
752 );
753 assert_eq!(
754 OAuthClientRegistrationStore::load(&store, "https://auth.example")
755 .await
756 .unwrap(),
757 Some(registration)
758 );
759 }
760
761 fn request() -> OAuthAuthorizationRequest {
762 OAuthAuthorizationRequest {
763 authorization_url: "https://auth.example/authorize?state=test".to_string(),
764 redirect_uri: "http://127.0.0.1:12345/callback".to_string(),
765 resource: "https://mcp.example/mcp".to_string(),
766 issuer: "https://auth.example".to_string(),
767 scopes: vec!["openid".to_string()],
768 }
769 }
770
771 #[tokio::test]
772 async fn browser_handler_has_an_automation_safe_seam() {
773 let opens = Arc::new(AtomicUsize::new(0));
774 let opener = {
775 let opens = opens.clone();
776 Arc::new(move |_url: &str| {
777 opens.fetch_add(1, Ordering::SeqCst);
778 Ok(())
779 }) as Arc<BrowserOpener>
780 };
781 let headless = BrowserAuthorizationHandler {
782 profile: Arc::from("work"),
783 interactive: false,
784 open_browser: true,
785 opener: opener.clone(),
786 };
787 let error = headless.authorize(request()).await.unwrap_err();
788 assert!(error.to_string().contains("--login work"));
789 assert_eq!(opens.load(Ordering::SeqCst), 0);
790
791 let manual = BrowserAuthorizationHandler {
792 profile: Arc::from("work"),
793 interactive: true,
794 open_browser: false,
795 opener: opener.clone(),
796 };
797 assert!(matches!(
798 manual.authorize(request()).await.unwrap(),
799 OAuthAuthorizationAction::AwaitLoopback
800 ));
801 assert_eq!(opens.load(Ordering::SeqCst), 0);
802
803 let browser = BrowserAuthorizationHandler {
804 profile: Arc::from("work"),
805 interactive: true,
806 open_browser: true,
807 opener,
808 };
809 assert!(matches!(
810 browser.authorize(request()).await.unwrap(),
811 OAuthAuthorizationAction::AwaitLoopback
812 ));
813 assert_eq!(opens.load(Ordering::SeqCst), 1);
814 }
815
816 #[test]
817 fn metadata_round_trip_never_contains_credentials() {
818 let temporary = tempfile::tempdir().unwrap();
819 let path = temporary.path().join("config.toml");
820 std::fs::write(&path, "[aliases]\nt = \"tools\"\n").unwrap();
821 let profile = OAuthProfile {
822 url: "https://mcp.example/mcp".to_string(),
823 scopes: vec!["openid".to_string(), "offline_access".to_string()],
824 client_id_metadata_document: Some("https://client.example/metadata.json".to_string()),
825 authorization_server: Some("https://auth.example".to_string()),
826 };
827
828 save_metadata(&path, "work", &profile).unwrap();
829 let source = std::fs::read_to_string(&path).unwrap();
830 assert!(source.contains("[oauth.work]"));
831 assert!(source.contains("[aliases]"));
832 assert!(!source.contains("token"));
833 assert!(!source.contains("secret"));
834 assert_eq!(
835 crate::config::Config::parse(&source).unwrap().oauth["work"],
836 profile
837 );
838
839 assert!(remove_metadata(&path, "work").unwrap());
840 let source = std::fs::read_to_string(path).unwrap();
841 assert!(!source.contains("[oauth"));
842 assert!(source.contains("[aliases]"));
843 }
844}