1use std::fs;
4use std::path::{Path, PathBuf};
5
6use async_trait::async_trait;
7use rmcp::transport::auth::{AuthError, CredentialStore, StoredCredentials};
8
9use crate::error::Result;
10use crate::fsutil::{atomic_write_0600, ensure_dir_0700};
11use crate::oauth::config::oauth_dir_for_server;
12
13const CREDENTIALS_FILE: &str = "credentials.json";
14const REDIRECT_META_FILE: &str = "redirect_uri.txt";
15
16#[derive(Debug, Clone)]
17pub struct FileCredentialStore {
18 dir: PathBuf,
19}
20
21impl FileCredentialStore {
22 pub fn for_server(server_url: &str) -> Self {
23 Self {
24 dir: oauth_dir_for_server(server_url),
25 }
26 }
27
28 pub fn from_dir(dir: PathBuf) -> Self {
29 Self { dir }
30 }
31
32 pub fn dir(&self) -> &Path {
33 &self.dir
34 }
35
36 fn credentials_path(&self) -> PathBuf {
37 self.dir.join(CREDENTIALS_FILE)
38 }
39
40 fn redirect_meta_path(&self) -> PathBuf {
41 self.dir.join(REDIRECT_META_FILE)
42 }
43
44 pub fn load_sticky_redirect_uri(&self) -> Option<String> {
45 fs::read_to_string(self.redirect_meta_path())
46 .ok()
47 .map(|s| s.trim().to_string())
48 .filter(|s| !s.is_empty())
49 }
50
51 pub fn save_sticky_redirect_uri(&self, uri: &str) -> Result<()> {
52 ensure_dir_0700(&self.dir)?;
53 atomic_write_0600(&self.redirect_meta_path(), uri.as_bytes())?;
54 Ok(())
55 }
56
57 pub fn clear_all(&self) -> Result<()> {
58 if self.dir.exists() {
59 fs::remove_dir_all(&self.dir)?;
60 }
61 Ok(())
62 }
63
64 pub fn load_sync(&self) -> Result<Option<StoredCredentials>> {
65 let path = self.credentials_path();
66 if !path.exists() {
67 return Ok(None);
68 }
69 let text = match fs::read_to_string(&path) {
70 Ok(t) => t,
71 Err(_) => return Ok(None),
72 };
73 match serde_json::from_str(&text) {
74 Ok(c) => Ok(Some(c)),
75 Err(_) => Ok(None),
76 }
77 }
78}
79
80fn atomic_write_restricted(path: &Path, bytes: &[u8]) -> Result<()> {
81 atomic_write_0600(path, bytes)
82}
83
84#[async_trait]
85impl CredentialStore for FileCredentialStore {
86 async fn load(&self) -> std::result::Result<Option<StoredCredentials>, AuthError> {
87 self.load_sync()
88 .map_err(|e| AuthError::InternalError(e.to_string()))
89 }
90
91 async fn save(&self, credentials: StoredCredentials) -> std::result::Result<(), AuthError> {
92 fs::create_dir_all(&self.dir).map_err(|e| AuthError::InternalError(e.to_string()))?;
93 #[cfg(unix)]
94 {
95 use std::os::unix::fs::PermissionsExt;
96 let _ = fs::set_permissions(&self.dir, fs::Permissions::from_mode(0o700));
97 }
98 let json = serde_json::to_vec_pretty(&credentials)
99 .map_err(|e| AuthError::InternalError(e.to_string()))?;
100 atomic_write_restricted(&self.credentials_path(), &json)
101 .map_err(|e| AuthError::InternalError(e.to_string()))
102 }
103
104 async fn clear(&self) -> std::result::Result<(), AuthError> {
105 let path = self.credentials_path();
106 if path.exists() {
107 fs::remove_file(&path).map_err(|e| AuthError::InternalError(e.to_string()))?;
108 }
109 Ok(())
110 }
111}
112
113pub fn clear_server_credentials(server_url: &str) -> Result<()> {
115 FileCredentialStore::for_server(server_url).clear_all()
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use crate::paths::{set_cache_dir_override, TEST_PATHS_LOCK};
122 use rmcp::transport::auth::StoredCredentials;
123 use tempfile::tempdir;
124
125 #[tokio::test]
126 async fn roundtrip_and_perms() {
127 let dir = {
128 let _g = TEST_PATHS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
129 let dir = tempdir().unwrap();
130 set_cache_dir_override(Some(dir.path().to_path_buf()));
131 dir
132 };
133 let store = FileCredentialStore::for_server("https://mcp.example.com");
134 let creds = StoredCredentials::new("cid".into(), None, vec![], None);
135 store.save(creds.clone()).await.unwrap();
136 let loaded = store.load().await.unwrap().unwrap();
137 assert_eq!(loaded.client_id, "cid");
138 #[cfg(unix)]
139 {
140 use std::os::unix::fs::PermissionsExt;
141 let mode = fs::metadata(store.credentials_path())
142 .unwrap()
143 .permissions()
144 .mode()
145 & 0o777;
146 assert_eq!(mode, 0o600);
147 }
148 store.clear().await.unwrap();
149 assert!(store.load().await.unwrap().is_none());
150 let _g = TEST_PATHS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
151 set_cache_dir_override(None);
152 drop(dir);
153 }
154
155 #[test]
156 fn sticky_redirect_roundtrip() {
157 let _g = TEST_PATHS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
158 let dir = tempdir().unwrap();
159 set_cache_dir_override(Some(dir.path().to_path_buf()));
160 let store = FileCredentialStore::for_server("https://sticky.example");
161 let uri = "http://127.0.0.1:4242/callback";
162 store.save_sticky_redirect_uri(uri).unwrap();
163 assert_eq!(store.load_sticky_redirect_uri().as_deref(), Some(uri));
164 store.clear_all().unwrap();
165 assert!(store.load_sticky_redirect_uri().is_none());
166 set_cache_dir_override(None);
167 }
168
169 #[test]
170 fn corrupt_returns_none() {
171 let _g = TEST_PATHS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
172 let dir = tempdir().unwrap();
173 set_cache_dir_override(Some(dir.path().to_path_buf()));
174 let store = FileCredentialStore::for_server("https://x");
175 fs::create_dir_all(store.dir()).unwrap();
176 fs::write(store.credentials_path(), "not-json").unwrap();
177 assert!(store.load_sync().unwrap().is_none());
178 set_cache_dir_override(None);
179 }
180}