Skip to main content

mcp_repl/
oauth_profile.rs

1//! Secure, named OAuth credentials for mcp-repl.
2//!
3//! The TOML config contains only routing and registration metadata. Access
4//! tokens, refresh tokens, and DCR client secrets are serialized into one
5//! profile record in the operating-system credential store.
6
7use 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";
25// Credential Manager stores passwords as UTF-16 in a 2,560-byte blob. Base64
26// of 768 raw bytes is 1,024 ASCII characters / 2,048 UTF-16 bytes, leaving
27// room below that platform limit. The other supported stores allow more.
28const 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/// Both persistence traits share one encrypted/keychain record and one lock,
258/// so token refresh and DCR updates cannot overwrite one another in-process.
259#[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", &registration)
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", &registration)
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}