Skip to main content

skiff_cli/oauth/
store.rs

1//! File-backed `CredentialStore` under `~/.cache/skiff/oauth/<hash>/`.
2
3use 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
113/// Clear all oauth credentials for a server URL (logout).
114pub 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}