Skip to main content

rig_core/providers/internal/auth/
store.rs

1//! Where sign-ins keep their credentials: the [`TokenStore`] trait, an
2//! in-memory store, and the owner-only file store native targets default to.
3
4use crate::error::BoxError;
5use crate::wasm_compat::{WasmBoxedFuture, WasmCompatSend, WasmCompatSync};
6use std::collections::HashMap;
7use std::sync::{Arc, Mutex, PoisonError};
8
9/// Keeps the credentials a sign-in writes and refreshes, each a JSON text
10/// under a key the provider names, such as `chatgpt` or `copilot`.
11///
12/// An authenticator reads its record before every request that needs one,
13/// and saves it after a sign-in, a refresh, or an invalidated token. It never
14/// touches storage except through its store. Clones of one authenticator
15/// share a refresh lock, and a store shared with other processes holds the
16/// record against them while a refresh or sign-in reads and saves it
17/// ([`lock`](Self::lock)), so one refresh runs at a time and each request
18/// reads what the last one saved. When a refresh token turns out to be spent, the
19/// authenticator reads the store again and takes a fresh credential another
20/// client or process saved there, before asking for a new sign-in.
21///
22/// ```
23/// use rig_core::providers::chatgpt::auth::{AuthSource, Authenticator, DeviceCodeHandler, MemoryTokenStore};
24/// use std::sync::Arc;
25///
26/// let store = Arc::new(MemoryTokenStore::default());
27/// let auth = Authenticator::new(AuthSource::OAuth, store, DeviceCodeHandler::default(), true);
28/// # let _ = auth;
29/// ```
30pub trait TokenStore: WasmCompatSend + WasmCompatSync {
31    /// The record kept under `key`, or `None` when there is none. An error
32    /// means the store could not answer, and fails the request.
33    fn load<'a>(&'a self, key: &'a str) -> WasmBoxedFuture<'a, Result<Option<String>, BoxError>>;
34
35    /// Keep `record` under `key`, replacing any record there.
36    fn save<'a>(
37        &'a self,
38        key: &'a str,
39        record: &'a str,
40    ) -> WasmBoxedFuture<'a, Result<(), BoxError>>;
41
42    /// Forget the record under `key`; forgetting a missing record succeeds.
43    fn delete<'a>(&'a self, key: &'a str) -> WasmBoxedFuture<'a, Result<(), BoxError>>;
44
45    /// Hold the record under `key` until the returned [`StoreLock`] is
46    /// dropped, against every other holder of it, in this process or
47    /// another (such as a refresh another rig runs at the same moment). The
48    /// default holds nothing: a store no other process reads needs no
49    /// lock beyond the authenticator's own.
50    fn lock<'a>(&'a self, _key: &'a str) -> WasmBoxedFuture<'a, Result<StoreLock, BoxError>> {
51        Box::pin(async { Ok(StoreLock::new(())) })
52    }
53}
54
55/// A record held by [`TokenStore::lock`], let go when dropped: it owns the
56/// store's guard and drops it then.
57pub struct StoreLock {
58    // Nothing reads the guard; the mutex only makes the lock `Sync` and
59    // unwind safe whatever the guard is.
60    _guard: Mutex<Box<dyn WasmCompatSend>>,
61}
62
63impl StoreLock {
64    /// Holds `guard`, any value whose drop releases the store's lock, such
65    /// as a locked file, an open database transaction or a lease on a
66    /// distributed lock, until this lock is dropped. A store with nothing
67    /// to release passes `()`.
68    ///
69    /// The guard's `Drop` runs synchronously, possibly inside an async task,
70    /// so it must not block. A release that needs I/O should spawn it from
71    /// `Drop` or let the lock's lease expire.
72    pub fn new(guard: impl WasmCompatSend + 'static) -> Self {
73        Self {
74            _guard: Mutex::new(Box::new(guard)),
75        }
76    }
77}
78
79impl std::fmt::Debug for StoreLock {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        f.debug_struct("StoreLock").finish_non_exhaustive()
82    }
83}
84
85impl<T: TokenStore + ?Sized> TokenStore for Arc<T> {
86    fn load<'a>(&'a self, key: &'a str) -> WasmBoxedFuture<'a, Result<Option<String>, BoxError>> {
87        (**self).load(key)
88    }
89
90    fn save<'a>(
91        &'a self,
92        key: &'a str,
93        record: &'a str,
94    ) -> WasmBoxedFuture<'a, Result<(), BoxError>> {
95        (**self).save(key, record)
96    }
97
98    fn delete<'a>(&'a self, key: &'a str) -> WasmBoxedFuture<'a, Result<(), BoxError>> {
99        (**self).delete(key)
100    }
101
102    fn lock<'a>(&'a self, key: &'a str) -> WasmBoxedFuture<'a, Result<StoreLock, BoxError>> {
103        (**self).lock(key)
104    }
105}
106
107/// A [`TokenStore`] in this process's memory, gone when it is dropped.
108#[derive(Default)]
109pub struct MemoryTokenStore(Mutex<HashMap<String, String>>);
110
111impl std::fmt::Debug for MemoryTokenStore {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        f.debug_struct("MemoryTokenStore").finish_non_exhaustive()
114    }
115}
116
117impl MemoryTokenStore {
118    fn records(&self) -> std::sync::MutexGuard<'_, HashMap<String, String>> {
119        self.0.lock().unwrap_or_else(PoisonError::into_inner)
120    }
121}
122
123impl TokenStore for MemoryTokenStore {
124    fn load<'a>(&'a self, key: &'a str) -> WasmBoxedFuture<'a, Result<Option<String>, BoxError>> {
125        Box::pin(async move { Ok(self.records().get(key).cloned()) })
126    }
127
128    fn save<'a>(
129        &'a self,
130        key: &'a str,
131        record: &'a str,
132    ) -> WasmBoxedFuture<'a, Result<(), BoxError>> {
133        Box::pin(async move {
134            self.records().insert(key.to_owned(), record.to_owned());
135            Ok(())
136        })
137    }
138
139    fn delete<'a>(&'a self, key: &'a str) -> WasmBoxedFuture<'a, Result<(), BoxError>> {
140        Box::pin(async move {
141            self.records().remove(key);
142            Ok(())
143        })
144    }
145}
146
147/// A [`TokenStore`] keeping each record in `<dir>/<key>.json`, written whole
148/// or not at all (a temporary file renamed over it), and held across
149/// processes with a file lock on `<dir>/<key>.lock`. On Unix the files, and
150/// the directories it creates for them, are readable by the owner alone.
151/// Native only.
152#[cfg(not(target_family = "wasm"))]
153#[derive(Debug, Clone)]
154pub struct FileTokenStore {
155    dir: std::path::PathBuf,
156}
157
158#[cfg(not(target_family = "wasm"))]
159impl FileTokenStore {
160    /// A store over the files in `dir`, created when first written.
161    pub fn new(dir: impl Into<std::path::PathBuf>) -> Self {
162        Self { dir: dir.into() }
163    }
164
165    /// The file the record under `key` is kept in.
166    pub fn path(&self, key: &str) -> std::path::PathBuf {
167        self.dir.join(format!("{key}.json"))
168    }
169}
170
171#[cfg(not(target_family = "wasm"))]
172impl TokenStore for FileTokenStore {
173    fn load<'a>(&'a self, key: &'a str) -> WasmBoxedFuture<'a, Result<Option<String>, BoxError>> {
174        Box::pin(async move {
175            match std::fs::read_to_string(self.path(key)) {
176                Ok(record) => Ok(Some(record)),
177                Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
178                Err(error) => Err(error.into()),
179            }
180        })
181    }
182
183    fn save<'a>(
184        &'a self,
185        key: &'a str,
186        record: &'a str,
187    ) -> WasmBoxedFuture<'a, Result<(), BoxError>> {
188        Box::pin(async move { Ok(write_private(&self.path(key), record.as_bytes())?) })
189    }
190
191    fn delete<'a>(&'a self, key: &'a str) -> WasmBoxedFuture<'a, Result<(), BoxError>> {
192        Box::pin(async move {
193            match std::fs::remove_file(self.path(key)) {
194                Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(error.into()),
195                _ => Ok(()),
196            }
197        })
198    }
199
200    /// Takes the file lock on `<dir>/<key>.lock`, waiting while another
201    /// holder has it, for at most two minutes. A process that dies lets go
202    /// of it.
203    fn lock<'a>(&'a self, key: &'a str) -> WasmBoxedFuture<'a, Result<StoreLock, BoxError>> {
204        Box::pin(async move {
205            let path = self.dir.join(format!("{key}.lock"));
206            create_private_dir(&path)?;
207            let mut options = std::fs::OpenOptions::new();
208            options.read(true).write(true).create(true).truncate(false);
209            #[cfg(unix)]
210            std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
211            let file = options.open(&path)?;
212            let started = std::time::Instant::now();
213            loop {
214                match file.try_lock() {
215                    Ok(()) => return Ok(StoreLock::new(file)),
216                    Err(std::fs::TryLockError::WouldBlock) if started.elapsed() < LOCK_WAIT => {
217                        crate::wasm_compat::sleep(std::time::Duration::from_millis(50)).await;
218                    }
219                    Err(std::fs::TryLockError::WouldBlock) => {
220                        return Err(format!(
221                            "another process has held {} for {} seconds",
222                            path.display(),
223                            LOCK_WAIT.as_secs()
224                        )
225                        .into());
226                    }
227                    Err(std::fs::TryLockError::Error(error)) => return Err(error.into()),
228                }
229            }
230        })
231    }
232}
233
234/// How long [`FileTokenStore::lock`] waits for another holder: far longer
235/// than a refresh's one request takes.
236#[cfg(not(target_family = "wasm"))]
237const LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(120);
238
239/// Creates the directories above `path`, 0700 on Unix.
240#[cfg(not(target_family = "wasm"))]
241fn create_private_dir(path: &std::path::Path) -> Result<(), std::io::Error> {
242    let Some(parent) = path.parent() else {
243        return Ok(());
244    };
245    let mut builder = std::fs::DirBuilder::new();
246    builder.recursive(true);
247    #[cfg(unix)]
248    std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
249    builder.create(parent)
250}
251
252/// Writes a credential file whole or not at all, creating the directories
253/// above it: the bytes go to a fresh temporary file beside it, which is
254/// synced and renamed over it, so a crash or a reader at the same moment
255/// never sees half a credential. On Unix the directories it creates are
256/// 0700, and the file is created 0600, or narrowed to 0600 before anything
257/// is written to it.
258#[cfg(not(target_family = "wasm"))]
259fn write_private(path: &std::path::Path, bytes: &[u8]) -> Result<(), std::io::Error> {
260    use std::io::Write;
261    use std::sync::atomic::{AtomicU64, Ordering};
262
263    static WRITES: AtomicU64 = AtomicU64::new(0);
264    create_private_dir(path)?;
265    let mut temp = path.as_os_str().to_owned();
266    temp.push(format!(
267        ".tmp-{}-{}",
268        std::process::id(),
269        WRITES.fetch_add(1, Ordering::Relaxed)
270    ));
271    let temp = std::path::PathBuf::from(temp);
272    let write = || {
273        let mut options = std::fs::OpenOptions::new();
274        options.write(true).create_new(true);
275        #[cfg(unix)]
276        std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
277        let mut file = options.open(&temp)?;
278        #[cfg(unix)]
279        file.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600))?;
280        file.write_all(bytes)?;
281        file.sync_all()?;
282        std::fs::rename(&temp, path)
283    };
284    let written = write();
285    if written.is_err() {
286        std::fs::remove_file(&temp).ok();
287    }
288    written
289}
290
291/// The JSON record under `key`, or `T::default()` when there is none.
292pub(crate) async fn load_record<T: Default + serde::de::DeserializeOwned>(
293    store: &dyn TokenStore,
294    key: &str,
295) -> Result<T, super::AuthError> {
296    match store.load(key).await.map_err(super::AuthError::Store)? {
297        Some(record) => Ok(serde_json::from_str(&record)?),
298        None => Ok(T::default()),
299    }
300}
301
302/// Keep `record` under `key` as JSON.
303pub(crate) async fn save_record<T: serde::Serialize>(
304    store: &dyn TokenStore,
305    key: &str,
306    record: &T,
307) -> Result<(), super::AuthError> {
308    let text = serde_json::to_string_pretty(record)?;
309    store
310        .save(key, &text)
311        .await
312        .map_err(super::AuthError::Store)
313}
314
315#[cfg(test)]
316mod tests;