Skip to main content

fxrs_auth/
lib.rs

1//! Provider-neutral, file-backed credential storage.
2//!
3//! Each provider gets one independently locked file under
4//! `~/.fx/credentials`. Providers own the meaning and refresh lifecycle of the
5//! credential; this crate owns only safe persistence and process coordination.
6
7use std::fs::{self, File, OpenOptions};
8use std::io::{Read, Write};
9use std::path::{Path, PathBuf};
10use std::thread;
11use std::time::{Duration, Instant};
12
13use atomic_write_file::AtomicWriteFile;
14use fs4::TryLockError;
15use fx_provider::{Credential, CredentialLease, CredentialStore, ProviderError};
16use serde::{Deserialize, Serialize};
17use zeroize::Zeroizing;
18
19const FORMAT_VERSION: u32 = 1;
20const MAX_CREDENTIAL_BYTES: u64 = 64 * 1024;
21const DEFAULT_LOCK_TIMEOUT: Duration = Duration::from_secs(5);
22
23#[derive(Clone, Debug)]
24pub struct FileCredentialStore {
25    root: PathBuf,
26    lock_timeout: Duration,
27}
28
29impl FileCredentialStore {
30    pub fn from_home(home: impl AsRef<Path>) -> Self {
31        Self::new(home.as_ref().join(".fx").join("credentials"))
32    }
33
34    pub fn new(root: impl Into<PathBuf>) -> Self {
35        Self {
36            root: root.into(),
37            lock_timeout: DEFAULT_LOCK_TIMEOUT,
38        }
39    }
40
41    pub fn with_lock_timeout(mut self, timeout: Duration) -> Self {
42        self.lock_timeout = timeout;
43        self
44    }
45
46    pub fn root(&self) -> &Path {
47        &self.root
48    }
49
50    fn acquire(&self, provider_id: &str) -> Result<FileCredentialLease<'_>, ProviderError> {
51        validate_provider_id(provider_id)?;
52        ensure_private_directory(&self.root)?;
53        let locks = self.root.join(".locks");
54        ensure_private_directory(&locks)?;
55        let lock_path = locks.join(format!("{provider_id}.lock"));
56        let lock = open_private_lock(&lock_path)?;
57        let deadline = Instant::now() + self.lock_timeout;
58        loop {
59            match fs4::FileExt::try_lock(&lock) {
60                Ok(()) => break,
61                Err(TryLockError::WouldBlock) if Instant::now() < deadline => {
62                    thread::sleep(Duration::from_millis(10));
63                }
64                Err(TryLockError::WouldBlock) => {
65                    return Err(ProviderError::CredentialStore(format!(
66                        "timed out locking credential for `{provider_id}`"
67                    )));
68                }
69                Err(TryLockError::Error(error)) => {
70                    return Err(store_io("locking credential", error));
71                }
72            }
73        }
74        let path = self.root.join(format!("{provider_id}.json"));
75        let credential = read_credential(&path)?;
76        Ok(FileCredentialLease {
77            _store: self,
78            _lock: lock,
79            path,
80            credential,
81        })
82    }
83}
84
85impl CredentialStore for FileCredentialStore {
86    fn lock<'a>(
87        &'a self,
88        provider_id: &str,
89    ) -> Result<Box<dyn CredentialLease + 'a>, ProviderError> {
90        Ok(Box::new(self.acquire(provider_id)?))
91    }
92}
93
94struct FileCredentialLease<'a> {
95    _store: &'a FileCredentialStore,
96    _lock: File,
97    path: PathBuf,
98    credential: Option<Credential>,
99}
100
101impl CredentialLease for FileCredentialLease<'_> {
102    fn credential(&self) -> Option<&Credential> {
103        self.credential.as_ref()
104    }
105
106    fn replace(&mut self, credential: Credential) -> Result<(), ProviderError> {
107        write_credential(&self.path, &credential)?;
108        self.credential = Some(credential);
109        Ok(())
110    }
111
112    fn delete(&mut self) -> Result<(), ProviderError> {
113        match fs::symlink_metadata(&self.path) {
114            Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
115                return Err(ProviderError::CredentialStore(format!(
116                    "credential path `{}` is not a regular file",
117                    self.path.display()
118                )));
119            }
120            Ok(_) => fs::remove_file(&self.path)
121                .map_err(|error| store_io("deleting credential", error))?,
122            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
123            Err(error) => return Err(store_io("inspecting credential", error)),
124        }
125        self.credential = None;
126        Ok(())
127    }
128}
129
130#[derive(Deserialize, Serialize)]
131#[serde(deny_unknown_fields)]
132struct StoredCredential {
133    version: u32,
134    credential: Credential,
135}
136
137fn read_credential(path: &Path) -> Result<Option<Credential>, ProviderError> {
138    let file = match open_read_no_follow(path) {
139        Ok(file) => file,
140        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
141        Err(error) => return Err(store_io("opening credential", error)),
142    };
143    let metadata = file
144        .metadata()
145        .map_err(|error| store_io("inspecting credential", error))?;
146    if !metadata.is_file() || !private_file_permissions(&metadata) {
147        return Err(ProviderError::CredentialStore(format!(
148            "credential `{}` is not a private regular file",
149            path.display()
150        )));
151    }
152    let mut bytes = Zeroizing::new(Vec::new());
153    file.take(MAX_CREDENTIAL_BYTES + 1)
154        .read_to_end(&mut bytes)
155        .map_err(|error| store_io("reading credential", error))?;
156    if bytes.len() as u64 > MAX_CREDENTIAL_BYTES {
157        return Err(ProviderError::CredentialStore(format!(
158            "credential `{}` exceeds 64 KiB",
159            path.display()
160        )));
161    }
162    let stored: StoredCredential = serde_json::from_slice(&bytes).map_err(|_| {
163        ProviderError::CredentialStore(format!(
164            "credential `{}` is corrupt or has an unsupported schema",
165            path.display()
166        ))
167    })?;
168    if stored.version != FORMAT_VERSION {
169        return Err(ProviderError::CredentialStore(format!(
170            "credential `{}` uses unsupported version {}",
171            path.display(),
172            stored.version
173        )));
174    }
175    Ok(Some(stored.credential))
176}
177
178fn write_credential(path: &Path, credential: &Credential) -> Result<(), ProviderError> {
179    if fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
180        return Err(ProviderError::CredentialStore(format!(
181            "credential `{}` is a symbolic link",
182            path.display()
183        )));
184    }
185    let stored = StoredCredential {
186        version: FORMAT_VERSION,
187        credential: credential.clone(),
188    };
189    let mut bytes = Zeroizing::new(
190        serde_json::to_vec(&stored)
191            .map_err(|error| ProviderError::CredentialStore(error.to_string()))?,
192    );
193    bytes.push(b'\n');
194    if bytes.len() as u64 > MAX_CREDENTIAL_BYTES {
195        return Err(ProviderError::CredentialStore(
196            "serialized credential exceeds 64 KiB".into(),
197        ));
198    }
199    let mut stage =
200        AtomicWriteFile::open(path).map_err(|error| store_io("staging credential", error))?;
201    set_private_file(stage.as_file())?;
202    stage
203        .write_all(&bytes)
204        .map_err(|error| store_io("writing credential", error))?;
205    stage
206        .commit()
207        .map_err(|error| store_io("committing credential", error))
208}
209
210fn validate_provider_id(value: &str) -> Result<(), ProviderError> {
211    let valid = !value.is_empty()
212        && value.len() <= 128
213        && value
214            .bytes()
215            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'));
216    if valid {
217        Ok(())
218    } else {
219        Err(ProviderError::CredentialStore(format!(
220            "unsafe provider id `{value}`"
221        )))
222    }
223}
224
225fn ensure_private_directory(path: &Path) -> Result<(), ProviderError> {
226    match fs::symlink_metadata(path) {
227        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
228            return Err(ProviderError::CredentialStore(format!(
229                "credential directory `{}` is not a directory",
230                path.display()
231            )));
232        }
233        Ok(_) => {}
234        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
235            fs::create_dir_all(path)
236                .map_err(|error| store_io("creating credential directory", error))?;
237        }
238        Err(error) => return Err(store_io("inspecting credential directory", error)),
239    }
240    #[cfg(unix)]
241    {
242        use std::os::unix::fs::PermissionsExt;
243        fs::set_permissions(path, fs::Permissions::from_mode(0o700))
244            .map_err(|error| store_io("securing credential directory", error))?;
245    }
246    Ok(())
247}
248
249fn open_private_lock(path: &Path) -> Result<File, ProviderError> {
250    if fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
251        return Err(ProviderError::CredentialStore(format!(
252            "credential lock `{}` is a symbolic link",
253            path.display()
254        )));
255    }
256    let mut options = OpenOptions::new();
257    options.create(true).read(true).write(true);
258    #[cfg(unix)]
259    {
260        use std::os::unix::fs::OpenOptionsExt;
261        options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
262    }
263    let file = options
264        .open(path)
265        .map_err(|error| store_io("opening credential lock", error))?;
266    set_private_file(&file)?;
267    Ok(file)
268}
269
270fn open_read_no_follow(path: &Path) -> std::io::Result<File> {
271    let mut options = OpenOptions::new();
272    options.read(true);
273    #[cfg(unix)]
274    {
275        use std::os::unix::fs::OpenOptionsExt;
276        options.custom_flags(libc::O_NOFOLLOW);
277    }
278    options.open(path)
279}
280
281fn set_private_file(file: &File) -> Result<(), ProviderError> {
282    #[cfg(unix)]
283    {
284        use std::os::unix::fs::PermissionsExt;
285        file.set_permissions(fs::Permissions::from_mode(0o600))
286            .map_err(|error| store_io("securing credential file", error))?;
287    }
288    Ok(())
289}
290
291fn private_file_permissions(metadata: &fs::Metadata) -> bool {
292    #[cfg(unix)]
293    {
294        use std::os::unix::fs::PermissionsExt;
295        metadata.permissions().mode() & 0o077 == 0
296    }
297    #[cfg(not(unix))]
298    {
299        let _ = metadata;
300        true
301    }
302}
303
304fn store_io(operation: &str, error: std::io::Error) -> ProviderError {
305    ProviderError::CredentialStore(format!("{operation}: {error}"))
306}
307
308#[cfg(test)]
309mod tests {
310    use std::collections::BTreeMap;
311
312    use super::*;
313
314    fn temporary(name: &str) -> PathBuf {
315        std::env::temp_dir().join(format!(
316            "fx-auth-{name}-{}-{}",
317            std::process::id(),
318            std::time::SystemTime::now()
319                .duration_since(std::time::UNIX_EPOCH)
320                .unwrap()
321                .as_nanos()
322        ))
323    }
324
325    fn key(value: &str) -> Credential {
326        Credential::ApiKey {
327            secret: value.into(),
328            attributes: BTreeMap::new(),
329        }
330    }
331
332    #[test]
333    fn isolates_provider_credentials_and_deletes_only_one() {
334        let root = temporary("isolation");
335        let store = FileCredentialStore::new(&root);
336        store.lock("alpha").unwrap().replace(key("a")).unwrap();
337        store.lock("beta").unwrap().replace(key("b")).unwrap();
338        assert!(store.lock("alpha").unwrap().credential().is_some());
339        store.lock("alpha").unwrap().delete().unwrap();
340        assert!(store.lock("alpha").unwrap().credential().is_none());
341        assert!(store.lock("beta").unwrap().credential().is_some());
342        fs::remove_dir_all(root).unwrap();
343    }
344
345    #[test]
346    fn corrupt_credentials_fail_closed() {
347        let root = temporary("corrupt");
348        fs::create_dir_all(&root).unwrap();
349        let path = root.join("alpha.json");
350        fs::write(&path, b"not-json").unwrap();
351        #[cfg(unix)]
352        {
353            use std::os::unix::fs::PermissionsExt;
354            fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
355        }
356        let error = FileCredentialStore::new(&root).lock("alpha").err().unwrap();
357        assert!(error.to_string().contains("corrupt"));
358        fs::remove_dir_all(root).unwrap();
359    }
360
361    #[cfg(unix)]
362    #[test]
363    fn stored_files_and_directories_are_private() {
364        use std::os::unix::fs::PermissionsExt;
365
366        let root = temporary("mode");
367        let store = FileCredentialStore::new(&root);
368        store.lock("alpha").unwrap().replace(key("a")).unwrap();
369        assert_eq!(
370            fs::metadata(&root).unwrap().permissions().mode() & 0o777,
371            0o700
372        );
373        assert_eq!(
374            fs::metadata(root.join("alpha.json"))
375                .unwrap()
376                .permissions()
377                .mode()
378                & 0o777,
379            0o600
380        );
381        fs::remove_dir_all(root).unwrap();
382    }
383}