Skip to main content

drep/
auth.rs

1//! The credential store: API keys drep holds on the user's behalf.
2//!
3//! `drep.toml` is a repository file. It names an endpoint, a model and a
4//! protocol, all of which are shareable, and it is meant to survive being
5//! committed - which is why `api_key = "${VAR}"` names an environment variable
6//! rather than holding a secret. That indirection is the right answer for CI,
7//! where the key arrives as a secret and nobody is at a keyboard, and the wrong
8//! answer for a person setting drep up on their laptop: it makes the first-run
9//! experience "now go and export something into the right shell profile".
10//!
11//! So keys live here instead, once per machine, outside any repository:
12//!
13//! ```text
14//! ~/.config/drep/auth.toml     (macOS: ~/Library/Application Support/dev.slb350.drep)
15//! ```
16//!
17//! ## Keyed by endpoint, not by provider name
18//!
19//! A key authenticates a *host*, so the endpoint is what it belongs to. Keying
20//! by a preset name instead would mean a config that named no preset - a custom
21//! endpoint, or one edited by hand after `drep init` - could not find its own
22//! credential, and two presets pointed at the same host would each need their
23//! own copy of one key.
24//!
25//! The endpoint is normalised before use (see [`normalise`]) so a trailing
26//! slash or a difference in case does not hide a key from the config that
27//! stored it.
28//!
29//! There is deliberately no `load_default`/`save_default` pair. Both were thin
30//! wrappers over `default_path()`, which reads the environment - so nothing
31//! could test them without `std::env::set_var`, and the mutation gate found
32//! them undetectable. Every caller resolves the path once, at its own entry
33//! point, and passes it down; that is also what keeps tests off the real store.
34//!
35//! ## Resolution order
36//!
37//! [`resolve`] fills in what `drep.toml` left unset, and an explicit value in
38//! the file always wins. A user who writes `api_key = "${OPENROUTER_API_KEY}"`
39//! has said where the key comes from, and silently preferring a stored one
40//! would make the file lie about what the run used.
41
42use std::collections::BTreeMap;
43use std::path::{Path, PathBuf};
44
45use serde::{Deserialize, Serialize};
46use thiserror::Error;
47
48use crate::config::Config;
49
50/// Where a provider's key came from, for [`doctor`](crate::cli::doctor) to report.
51///
52/// The point of naming the source is that "it works on my machine" and "it works
53/// in CI" are different configurations, and the difference is invisible in
54/// `drep.toml` once a stored key exists.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum KeySource {
57    /// The config named an environment variable or a literal, and it resolved.
58    Config,
59    /// The config named nothing and the store had one for this endpoint.
60    Store,
61    /// Neither. The provider will authenticate as `not-needed`, which a local
62    /// server accepts and a cloud one answers with a 401.
63    Missing,
64}
65
66impl KeySource {
67    /// What `doctor` prints for this source.
68    ///
69    /// The single definition of the wording. `doctor` hand-wrote its own copy
70    /// of these three strings while this method sat unused, which is two places
71    /// to keep in step for no gain.
72    pub fn label(&self) -> &'static str {
73        match self {
74            Self::Config => "from drep.toml",
75            Self::Store => "from the drep auth store",
76            Self::Missing => "not set - run `drep auth login` or add `api_key` to drep.toml",
77        }
78    }
79}
80
81/// Keys held for this machine, keyed by normalised endpoint.
82///
83/// `Serialize`/`Deserialize` drive the on-disk TOML directly; there is no
84/// separate wire type because the file is drep's own and has one shape.
85#[derive(Default, Serialize, Deserialize)]
86pub struct AuthStore {
87    /// Endpoint to key. A `BTreeMap` so the file is written in a stable order
88    /// and a re-save produces no spurious diff.
89    #[serde(default)]
90    keys: BTreeMap<String, String>,
91}
92
93/// Hand-written so a key cannot reach a log.
94///
95/// The same reasoning as `LlmConfig` and `LlmClient`: a derived `Debug` prints
96/// every value, so one `{:?}` anywhere would emit every credential the user has.
97/// The endpoints are printed because they are not secret and they are the useful
98/// half when debugging.
99impl std::fmt::Debug for AuthStore {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.debug_struct("AuthStore")
102            .field("endpoints", &self.keys.keys().collect::<Vec<_>>())
103            .finish()
104    }
105}
106
107/// What can go wrong reading or writing the store.
108#[derive(Debug, Error)]
109pub enum AuthError {
110    #[error("no config directory for this platform; set the key in drep.toml instead")]
111    NoConfigDir,
112
113    #[error("could not read {0}: {1}")]
114    Read(PathBuf, std::io::Error),
115
116    #[error("could not write {0}: {1}")]
117    Write(PathBuf, std::io::Error),
118
119    #[error("could not parse {0}: {1}")]
120    Parse(PathBuf, String),
121
122    /// Serializing the store failed. Distinct from [`Self::Parse`], which is
123    /// about reading: a write failure reported as "could not parse" sends the
124    /// reader looking at the file rather than at what drep tried to write.
125    #[error("could not serialize the auth store: {0}")]
126    Serialize(String),
127
128    #[error("refusing to store an empty key for {0}")]
129    EmptyKey(String),
130
131    /// A key carrying a control character cannot be sent as an HTTP header, so
132    /// storing it would defer a guaranteed failure to the first request.
133    #[error("the key for {0} contains a character that cannot be sent in a header")]
134    UnusableKey(String),
135}
136
137/// The environment variable that relocates the store.
138pub const PATH_VAR: &str = "DREP_AUTH_PATH";
139
140/// The store location: [`PATH_VAR`] if set, else the platform's config dir.
141///
142/// The platform path comes from `directories::ProjectDirs` with the same triple
143/// `Cache::default_root` uses, so drep's two user-level directories are siblings
144/// under one application identity rather than two unrelated paths.
145///
146/// The override exists because there is otherwise **no way to run drep against a
147/// scratch store**. `directories` follows each platform's own convention rather
148/// than the XDG variables, so on macOS `XDG_CONFIG_HOME` is ignored entirely and
149/// a command run to try something out writes into the real store - which is how
150/// a test key ended up in one. It also serves the ordinary case of keeping
151/// credentials somewhere deliberate, such as a mounted volume.
152pub fn default_path() -> Result<PathBuf, AuthError> {
153    path_from(std::env::var_os(PATH_VAR))
154}
155
156/// [`default_path`] with the override supplied rather than read.
157///
158/// Split out so the override can be tested without writing to the process
159/// environment. `std::env::set_var` is `unsafe` in edition 2024 because another
160/// thread reading the environment concurrently is a data race, and `cargo test`
161/// runs tests on several threads - a "single-threaded test process" safety
162/// comment would simply have been untrue.
163pub fn path_from(overridden: Option<std::ffi::OsString>) -> Result<PathBuf, AuthError> {
164    if let Some(path) = overridden {
165        return Ok(PathBuf::from(path));
166    }
167    directories::ProjectDirs::from("dev", "slb350", "drep")
168        .map(|dirs| dirs.config_dir().join("auth.toml"))
169        .ok_or(AuthError::NoConfigDir)
170}
171
172impl AuthStore {
173    /// An empty store, for a machine that has never stored a key.
174    pub fn new() -> Self {
175        Self::default()
176    }
177
178    /// Read the store at `path`.
179    ///
180    /// A **missing file is an empty store**, not an error: never having stored a
181    /// key is the normal first-run state, and making the caller distinguish it
182    /// from a real read failure would put that branch at every call site. A file
183    /// that exists but cannot be read or parsed *is* an error, because silently
184    /// treating a corrupt store as empty would send a user to re-paste keys they
185    /// already have.
186    pub fn load(path: &Path) -> Result<Self, AuthError> {
187        let content = match std::fs::read_to_string(path) {
188            Ok(content) => content,
189            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Self::new()),
190            Err(err) => return Err(AuthError::Read(path.to_path_buf(), err)),
191        };
192        toml::from_str(&content)
193            .map_err(|err: toml::de::Error| AuthError::Parse(path.to_path_buf(), err.to_string()))
194    }
195
196    /// Write the store to `path`, creating the directory if needed.
197    ///
198    /// The file is created mode 0600 and the directory 0700 on Unix, and the
199    /// mode is applied to an *existing* file too - a store written before this
200    /// ran, or one whose mode a user widened, is narrowed on the next save
201    /// rather than left as found.
202    pub fn save(&self, path: &Path) -> Result<(), AuthError> {
203        // A bare filename has `Some("")` as its parent, which is not a
204        // directory anything can create.
205        if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
206            ensure_dir_private(parent)?;
207        }
208
209        let body =
210            toml::to_string_pretty(self).map_err(|err| AuthError::Serialize(err.to_string()))?;
211
212        // Created 0600 from the outset rather than written and then chmodded:
213        // between those two steps the key sits in a world-readable file, which
214        // is a window another process on a shared machine can read.
215        write_private(path, &body)
216    }
217
218    /// The key held for `endpoint`, if any.
219    pub fn get(&self, endpoint: &str) -> Option<&str> {
220        self.keys.get(&normalise(endpoint)).map(String::as_str)
221    }
222
223    /// Store `key` for `endpoint`, replacing any previous one.
224    ///
225    /// An empty key is rejected rather than stored: it would satisfy every
226    /// "is a key present" check and then fail at the endpoint with a 401, which
227    /// is the confusing-empty-credential failure `${VAR}` expansion already
228    /// refuses for the same reason. Surrounding whitespace is trimmed, because a
229    /// pasted key routinely carries a trailing newline.
230    pub fn set(&mut self, endpoint: &str, key: &str) -> Result<(), AuthError> {
231        let key = key.trim();
232        if key.is_empty() {
233            return Err(AuthError::EmptyKey(endpoint.to_string()));
234        }
235        // Every use of a stored key is an HTTP header value, which cannot carry
236        // a control character. Rejecting here turns a paste that picked up a
237        // stray newline or an escape sequence into a message at the prompt,
238        // rather than a transport failure on the first file of the first push.
239        if key.chars().any(|c| c.is_control()) {
240            return Err(AuthError::UnusableKey(endpoint.to_string()));
241        }
242        self.keys.insert(normalise(endpoint), key.to_string());
243        Ok(())
244    }
245
246    /// Forget the key for `endpoint`. Returns whether one was held.
247    pub fn remove(&mut self, endpoint: &str) -> bool {
248        self.keys.remove(&normalise(endpoint)).is_some()
249    }
250
251    /// Every endpoint with a stored key, in sorted order. Never the keys.
252    pub fn endpoints(&self) -> Vec<&str> {
253        self.keys.keys().map(String::as_str).collect()
254    }
255
256    /// Whether the store holds nothing.
257    pub fn is_empty(&self) -> bool {
258        self.keys.is_empty()
259    }
260}
261
262/// Canonical form of an endpoint for use as a store key.
263///
264/// Lowercased and stripped of trailing slashes. `https://API.Z.AI/v1/` and
265/// `https://api.z.ai/v1` are the same host and the same credential, and a store
266/// that disagreed would report "no key" for a config that had just stored one -
267/// a failure with no visible cause, since both spellings look right.
268///
269/// Deliberately no more than that: the path is significant (`/v1` and
270/// `/anthropic/v1` are different APIs on the same host, and can carry different
271/// keys), so nothing beyond the trailing slash is trimmed.
272pub fn normalise(endpoint: &str) -> String {
273    let trimmed = endpoint.trim().trim_end_matches('/');
274
275    // Only the scheme and authority are case-insensitive; a URL *path* is not.
276    // Lowercasing the whole thing collapsed `/API/v1` and `/api/v1` onto one
277    // entry, which for a host serving both would hand one endpoint's key to the
278    // other - the same class of mistake as keying on the model alone.
279    match trimmed.find("://") {
280        Some(scheme_end) => format!(
281            "{}://{}",
282            trimmed[..scheme_end].to_ascii_lowercase(),
283            lower_authority(&trimmed[scheme_end + 3..])
284        ),
285        // No scheme, which is what `localhost:11434/v1` looks like. It still has
286        // an authority and a path, and the same rule applies to both halves -
287        // lowercasing the whole string collapsed `/V1` onto `/v1` for exactly
288        // the endpoints a user is most likely to type by hand.
289        None => lower_authority(trimmed),
290    }
291}
292
293/// Lowercase everything before the first `/` and leave the rest alone.
294fn lower_authority(rest: &str) -> String {
295    let host_len = rest.find('/').unwrap_or(rest.len());
296    format!(
297        "{}{}",
298        rest[..host_len].to_ascii_lowercase(),
299        &rest[host_len..]
300    )
301}
302
303/// Fill in keys the config left unset, and report where each one came from.
304///
305/// Returns one [`KeySource`] per entry in `config.llm`, positionally - including
306/// the disabled ones, so a caller numbering providers by file position and a
307/// caller numbering by chain position both index correctly.
308///
309/// **Disabled entries are skipped**, matching every other pass over the provider
310/// list: `${VAR}` expansion and field validation already leave a parked entry
311/// alone, and looking a key up for one would report a missing credential for a
312/// provider that is never contacted.
313pub fn resolve(config: &mut Config, store: &AuthStore) -> Vec<KeySource> {
314    config
315        .llm
316        .iter_mut()
317        .map(|llm| {
318            let source = source_of(
319                llm.api_key.as_deref(),
320                llm.endpoint.as_deref(),
321                llm.enabled,
322                store,
323            );
324            if source == KeySource::Store
325                && let Some(endpoint) = llm.endpoint.as_deref()
326                && let Some(key) = store.get(endpoint)
327            {
328                llm.api_key = Some(key.to_string());
329            }
330            source
331        })
332        .collect()
333}
334
335/// Where a provider's key will come from, given what its config names.
336///
337/// The precedence rule itself, in one place. [`resolve`] applies it to a loaded
338/// `Config`; `doctor` applies it to the *raw* TOML tree, which it has to read
339/// separately so a `${VAR}` prints as itself rather than being swallowed by the
340/// variable-not-set error. Two readers of one rule is the shape
341/// `config::env_var_refs_in` already exists to prevent: doctor once carried a
342/// narrower copy of that scanner and reported a config as fine that `check`
343/// refused to load.
344pub fn source_of(
345    api_key: Option<&str>,
346    endpoint: Option<&str>,
347    enabled: bool,
348    store: &AuthStore,
349) -> KeySource {
350    if !enabled {
351        return KeySource::Missing;
352    }
353    if api_key.is_some() {
354        return KeySource::Config;
355    }
356    match endpoint {
357        Some(endpoint) if store.get(endpoint).is_some() => KeySource::Store,
358        _ => KeySource::Missing,
359    }
360}
361
362/// Create `dir` if it is missing, narrowing it to 0700 only when drep made it.
363///
364/// Only a directory drep creates is narrowed. `DREP_AUTH_PATH` can name any
365/// path, so chmodding whatever happens to be its parent would let
366/// `/etc/drep.toml` turn `/etc` into 0700 - breaking the system to protect one
367/// file. An existing directory is the user's, and the store file's own 0600 is
368/// what actually guards the key.
369///
370/// Shared with the model-quirks cache, which sits in the same directory: a
371/// second copy of this rule that only called `create_dir_all` would leave the
372/// credential store's directory world-readable whenever the cache happened to
373/// be written first.
374pub(crate) fn ensure_dir_private(dir: &Path) -> Result<(), AuthError> {
375    let existed = dir.exists();
376    std::fs::create_dir_all(dir).map_err(|err| AuthError::Write(dir.to_path_buf(), err))?;
377    if !existed {
378        restrict(dir, 0o700)?;
379    }
380    Ok(())
381}
382
383/// Write `body` to `path`, creating it readable only by its owner.
384///
385/// `File::create` plus a later `chmod` leaves the key in a 0644 file for the
386/// duration of the write. `OpenOptions::mode` applies the mode at *creation*,
387/// so there is no window. The mode is re-applied afterwards because it only
388/// affects creation - an existing file keeps whatever mode it had, including
389/// one a user widened.
390///
391/// One function with the `cfg` around the mode call, rather than two whole
392/// implementations: a `#[cfg(not(unix))]` twin is not compiled here, so
393/// mutating it changes nothing and the mutation gate reports an undetectable
394/// survivor on every run. Windows has no mode bits, and `directories` puts the
395/// file under the user's own roaming profile there.
396fn write_private(path: &Path, body: &str) -> Result<(), AuthError> {
397    use std::io::Write;
398
399    // Written beside the target and renamed over it, never into it. Opening the
400    // real path with `truncate` destroys the existing store before a byte of the
401    // replacement is written, so a crash, a full disk or a serialization failure
402    // in that window leaves the file empty or half-written - and this is the one
403    // file drep holds that cannot be regenerated. `rename` is atomic within a
404    // directory, so a reader sees either the whole old store or the whole new
405    // one, which is why the temporary is a sibling rather than in the system
406    // temp dir.
407    let temporary = temp_beside(path);
408
409    let mut options = std::fs::OpenOptions::new();
410    options.write(true).create(true).truncate(true);
411    #[cfg(unix)]
412    {
413        use std::os::unix::fs::OpenOptionsExt;
414        options.mode(0o600);
415    }
416
417    let mut file = options
418        .open(&temporary)
419        .map_err(|err| AuthError::Write(temporary.clone(), err))?;
420    file.write_all(body.as_bytes())
421        .map_err(|err| AuthError::Write(temporary.clone(), err))?;
422    // Before the rename, not after: a rename that publishes a file whose
423    // contents are still in the page cache can survive a crash as an empty one.
424    file.sync_all()
425        .map_err(|err| AuthError::Write(temporary.clone(), err))?;
426    drop(file);
427
428    // The temporary carries the mode, and `rename` keeps it - so the published
429    // store is 0600 whatever the mode of the file it replaced, which is how a
430    // store a user widened is narrowed again.
431    restrict(&temporary, 0o600)?;
432
433    std::fs::rename(&temporary, path).map_err(|err| {
434        // Otherwise a repeatedly-failing save leaves one temporary per attempt
435        // beside the store.
436        let _ = std::fs::remove_file(&temporary);
437        AuthError::Write(path.to_path_buf(), err)
438    })
439}
440
441/// A sibling of `path` to write before renaming over it.
442///
443/// The whole file name is kept and a suffix appended, rather than
444/// `with_extension`, which would turn `auth.toml` into `auth.tmp` and collide
445/// with anything else following the same convention. `DREP_AUTH_PATH` can name
446/// a file with no extension at all.
447fn temp_beside(path: &Path) -> std::path::PathBuf {
448    let mut name = path.file_name().unwrap_or_default().to_os_string();
449    name.push(".drep-tmp");
450    path.with_file_name(name)
451}
452
453/// Narrow `path` to `mode` on Unix. A no-op elsewhere.
454///
455/// Windows has no mode bits and `directories` puts the file under the user's
456/// roaming profile, which is already user-scoped; failing the save there would
457/// refuse to store a key for no gain.
458#[cfg(unix)]
459fn restrict(path: &Path, mode: u32) -> Result<(), AuthError> {
460    use std::os::unix::fs::PermissionsExt;
461    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
462        .map_err(|err| AuthError::Write(path.to_path_buf(), err))
463}
464
465#[cfg(not(unix))]
466fn restrict(_path: &Path, _mode: u32) -> Result<(), AuthError> {
467    Ok(())
468}
469
470#[cfg(test)]
471mod tests;