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, in this order:
38//!
39//! 1. an explicit `api_key` - a literal, or a `${VAR}` the loader has already
40//!    substituted;
41//! 2. `api_key_command`, an argv drep runs to mint one;
42//! 3. a key held here for the same endpoint;
43//! 4. nothing, which `LlmClient::new` turns into an empty key so the SDK sends
44//!    no protocol authentication header.
45//!
46//! An explicit value in the file always wins. A user who writes
47//! `api_key = "${OPENROUTER_API_KEY}"` has said where the key comes from, and
48//! silently preferring a stored one would make the file lie about what the run
49//! used. The command sits above the store for the same reason: a file naming a
50//! command has not left the question unanswered.
51
52use std::borrow::Cow;
53use std::collections::{BTreeMap, BTreeSet};
54use std::path::PathBuf;
55
56use serde::{Deserialize, Serialize};
57use thiserror::Error;
58
59use crate::config::Config;
60
61mod command;
62mod store_file;
63pub use command::KeyCommandError;
64pub(crate) use store_file::ensure_dir_private;
65#[cfg(test)]
66pub(crate) use store_file::{restrict, temporary_parent};
67
68/// Where a provider's key came from, for [`doctor`](crate::cli::doctor) to report.
69///
70/// The point of naming the source is that "it works on my machine" and "it works
71/// in CI" are different configurations, and the difference is invisible in
72/// `drep.toml` once a stored key exists.
73///
74/// The variant order is the resolution order.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum KeySource {
77    /// The config named an environment variable or a literal, and it resolved.
78    Config,
79    /// The config named an `api_key_command`, which drep runs to mint a key.
80    Command,
81    /// The config named nothing and the store had one for this endpoint.
82    Store,
83    /// Neither. The provider sends no protocol authentication header; a custom
84    /// header may still provide the endpoint's complete authentication scheme.
85    Missing,
86}
87
88impl KeySource {
89    /// Every source, in resolution order.
90    ///
91    /// Exists so the label-collision test cannot pass on a stale subset, the way
92    /// `Severity::ALL` drives its parser test: a variant added without wording of
93    /// its own has to fail something.
94    pub const ALL: [Self; 4] = [Self::Config, Self::Command, Self::Store, Self::Missing];
95
96    /// What `doctor` prints for this source.
97    ///
98    /// The single definition of the wording. `doctor` hand-wrote its own copy
99    /// of these three strings while this method sat unused, which is two places
100    /// to keep in step for no gain.
101    pub fn label(&self) -> &'static str {
102        match self {
103            Self::Config => "from drep.toml",
104            Self::Command => "from api_key_command",
105            Self::Store => "from the drep auth store",
106            Self::Missing => "not set - run `drep auth login` or add `api_key` to drep.toml",
107        }
108    }
109}
110
111/// Keys held for this machine, keyed by normalised endpoint.
112///
113/// `Serialize`/`Deserialize` drive the on-disk TOML directly; there is no
114/// separate wire type because the file is drep's own and has one shape.
115#[derive(Default, Serialize, Deserialize)]
116pub struct AuthStore {
117    /// Endpoint to key. A `BTreeMap` so the file is written in a stable order
118    /// and a re-save produces no spurious diff.
119    #[serde(default)]
120    keys: BTreeMap<String, String>,
121
122    /// Mutations made since this snapshot was loaded.
123    ///
124    /// Saving replays only these operations over the latest on-disk store, so
125    /// two drep processes changing different endpoints cannot overwrite one
126    /// another. The journal is process state, never part of the TOML file.
127    #[serde(skip)]
128    pending: std::sync::Mutex<BTreeSet<String>>,
129}
130
131/// Hand-written so a key cannot reach a log.
132///
133/// The same reasoning as `LlmConfig` and `LlmClient`: a derived `Debug` prints
134/// every value, so one `{:?}` anywhere would emit every credential the user has.
135/// The endpoints are printed because they are not secret and they are the useful
136/// half when debugging.
137impl std::fmt::Debug for AuthStore {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.debug_struct("AuthStore")
140            .field("endpoints", &self.keys.keys().collect::<Vec<_>>())
141            .finish()
142    }
143}
144
145/// What can go wrong reading or writing the store.
146#[derive(Debug, Error)]
147pub enum AuthError {
148    #[error("no config directory for this platform; set the key in drep.toml instead")]
149    NoConfigDir,
150
151    #[error("could not read {0}: {1}")]
152    Read(PathBuf, std::io::Error),
153
154    #[error("could not write {0}: {1}")]
155    Write(PathBuf, std::io::Error),
156
157    #[error("could not lock auth store {0}: {1}")]
158    Lock(PathBuf, std::io::Error),
159
160    #[error("could not parse {0}: {1}")]
161    Parse(PathBuf, String),
162
163    /// Serializing the store failed. Distinct from [`Self::Parse`], which is
164    /// about reading: a write failure reported as "could not parse" sends the
165    /// reader looking at the file rather than at what drep tried to write.
166    #[error("could not serialize the auth store: {0}")]
167    Serialize(String),
168
169    #[error("refusing to store an empty key for {0}")]
170    EmptyKey(String),
171
172    /// A key carrying a control character cannot be sent as an HTTP header, so
173    /// storing it would defer a guaranteed failure to the first request.
174    #[error("the key for {0} contains a character that cannot be sent in a header")]
175    UnusableKey(String),
176
177    /// A configured `api_key_command` did not produce a credential.
178    ///
179    /// Fatal, and fatal *here*: the provider chain does not exist yet, so there
180    /// is nothing to fail over to - and failing over would be the wrong answer
181    /// anyway, for the reason a 401 does not fail over. Routing around a broken
182    /// credential path is what hides it.
183    ///
184    /// The entry is numbered in file order, matching `ConfigError`, because that
185    /// is the numbering a user can count blocks in `drep.toml` to check.
186    #[error("[[llm]] #{} in file order: {cause}", index + 1)]
187    KeyCommand {
188        index: usize,
189        cause: KeyCommandError,
190    },
191}
192
193/// The environment variable that relocates the store.
194pub const PATH_VAR: &str = "DREP_AUTH_PATH";
195
196/// The store location: [`PATH_VAR`] if set, else the platform's config dir.
197///
198/// The platform path comes from `directories::ProjectDirs` with the same triple
199/// `Cache::default_root` uses, so drep's two user-level directories are siblings
200/// under one application identity rather than two unrelated paths.
201///
202/// The override exists because there is otherwise **no way to run drep against a
203/// scratch store**. `directories` follows each platform's own convention rather
204/// than the XDG variables, so on macOS `XDG_CONFIG_HOME` is ignored entirely and
205/// a command run to try something out writes into the real store - which is how
206/// a test key ended up in one. It also serves the ordinary case of keeping
207/// credentials somewhere deliberate, such as a mounted volume.
208pub fn default_path() -> Result<PathBuf, AuthError> {
209    path_from(std::env::var_os(PATH_VAR))
210}
211
212/// [`default_path`] with the override supplied rather than read.
213///
214/// Split out so the override can be tested without writing to the process
215/// environment. `std::env::set_var` is `unsafe` in edition 2024 because another
216/// thread reading the environment concurrently is a data race, and `cargo test`
217/// runs tests on several threads - a "single-threaded test process" safety
218/// comment would simply have been untrue.
219pub fn path_from(overridden: Option<std::ffi::OsString>) -> Result<PathBuf, AuthError> {
220    if let Some(path) = overridden {
221        return Ok(PathBuf::from(path));
222    }
223    directories::ProjectDirs::from("dev", "slb350", "drep")
224        .map(|dirs| dirs.config_dir().join("auth.toml"))
225        .ok_or(AuthError::NoConfigDir)
226}
227
228impl AuthStore {
229    /// An empty store, for a machine that has never stored a key.
230    pub fn new() -> Self {
231        Self::default()
232    }
233
234    /// The key held for `endpoint`, if any.
235    pub fn get(&self, endpoint: &str) -> Option<&str> {
236        self.keys.get(&normalise(endpoint)).map(String::as_str)
237    }
238
239    /// Store `key` for `endpoint`, replacing any previous one.
240    ///
241    /// The rule about what a credential may be is `vet`'s, not this method's;
242    /// only the wording is here, because a paste at the prompt and a helper's
243    /// stdout send the reader to different fixes.
244    pub fn set(&mut self, endpoint: &str, key: &str) -> Result<(), AuthError> {
245        let key = vet(key).map_err(|defect| match defect {
246            CredentialDefect::Empty => AuthError::EmptyKey(endpoint.to_string()),
247            CredentialDefect::Unusable => AuthError::UnusableKey(endpoint.to_string()),
248        })?;
249        let endpoint = normalise(endpoint);
250        self.keys.insert(endpoint.clone(), key);
251        self.pending
252            .get_mut()
253            .unwrap_or_else(std::sync::PoisonError::into_inner)
254            .insert(endpoint);
255        Ok(())
256    }
257
258    /// Forget the key for `endpoint`. Returns whether one was held.
259    pub fn remove(&mut self, endpoint: &str) -> bool {
260        let endpoint = normalise(endpoint);
261        let removed = self.keys.remove(&endpoint).is_some();
262        if removed {
263            self.pending
264                .get_mut()
265                .unwrap_or_else(std::sync::PoisonError::into_inner)
266                .insert(endpoint);
267        }
268        removed
269    }
270
271    /// Every endpoint with a stored key, in sorted order. Never the keys.
272    pub fn endpoints(&self) -> Vec<&str> {
273        self.keys.keys().map(String::as_str).collect()
274    }
275
276    /// Whether the store holds nothing.
277    pub fn is_empty(&self) -> bool {
278        self.keys.is_empty()
279    }
280}
281
282/// Why a credential cannot be used, whatever produced it.
283///
284/// Two variants because they send the reader to different fixes: nothing arrived
285/// at all, or something arrived that cannot be sent.
286pub(crate) enum CredentialDefect {
287    Empty,
288    Unusable,
289}
290
291/// Trim a candidate credential and refuse one that cannot become a header value.
292///
293/// One definition, because this is a safety property, and `crate::http`'s module
294/// doc already records what happens to a safety property written twice: "written
295/// once and forgotten once". It was written twice - here for a key pasted at the
296/// prompt, and in `auth::command::run` for one a helper printed - and the two
297/// copies had already drifted cosmetically. The minted path is the one that never
298/// passes through [`AuthStore::set`], so a defect class added at the prompt would
299/// silently not have applied to the credential a gateway helper produces, which is
300/// the only path whose value nobody ever sees.
301///
302/// The wording stays with each caller: this returns [`CredentialDefect`] rather
303/// than an `AuthError`, so the store can name the endpoint and the helper can name
304/// the program. That is the mechanism-shared, classification-private split
305/// `crate::http` documents for the same pair of callers.
306///
307/// **Both ends** are trimmed. Every helper that prints a token prints a newline
308/// after it, and a leading space is one `printf ' %s'` or one `cut` field away;
309/// trimming only the tail accepted `" sk-live-..."`, which passes the
310/// control-character guard and is then stripped again by the transport, so the
311/// value sent was not the value checked and the endpoint answered 401.
312///
313/// A control character is refused because every use of a credential is an HTTP
314/// header value, which cannot carry one. Rejecting it here turns a paste or a
315/// helper that picked up a stray escape sequence into a message the user can act
316/// on, rather than a transport failure on the first file of the first push.
317pub(crate) fn vet(candidate: &str) -> Result<String, CredentialDefect> {
318    let candidate = candidate.trim();
319    if candidate.is_empty() {
320        return Err(CredentialDefect::Empty);
321    }
322    if candidate.chars().any(char::is_control) {
323        return Err(CredentialDefect::Unusable);
324    }
325    Ok(candidate.to_owned())
326}
327
328/// Canonical form of an endpoint for use as a store key.
329///
330/// Lowercased and stripped of trailing slashes. `https://API.Z.AI/v1/` and
331/// `https://api.z.ai/v1` are the same host and the same credential, and a store
332/// that disagreed would report "no key" for a config that had just stored one -
333/// a failure with no visible cause, since both spellings look right.
334///
335/// Deliberately no more than that: the path is significant (`/v1` and
336/// `/anthropic/v1` are different APIs on the same host, and can carry different
337/// keys), so nothing beyond the trailing slash is trimmed.
338pub fn normalise(endpoint: &str) -> String {
339    let trimmed = endpoint.trim().trim_end_matches('/');
340
341    // Only the scheme and authority are case-insensitive; a URL *path* is not.
342    // Lowercasing the whole thing collapsed `/API/v1` and `/api/v1` onto one
343    // entry, which for a host serving both would hand one endpoint's key to the
344    // other - the same class of mistake as keying on the model alone.
345    match trimmed.find("://") {
346        Some(scheme_end) => format!(
347            "{}://{}",
348            trimmed[..scheme_end].to_ascii_lowercase(),
349            lower_authority(&trimmed[scheme_end + 3..])
350        ),
351        // No scheme, which is what `localhost:11434/v1` looks like. It still has
352        // an authority and a path, and the same rule applies to both halves -
353        // lowercasing the whole string collapsed `/V1` onto `/v1` for exactly
354        // the endpoints a user is most likely to type by hand.
355        None => lower_authority(trimmed),
356    }
357}
358
359/// Lowercase everything before the first `/` and leave the rest alone.
360fn lower_authority(rest: &str) -> String {
361    let host_len = rest.find('/').unwrap_or(rest.len());
362    format!(
363        "{}{}",
364        rest[..host_len].to_ascii_lowercase(),
365        &rest[host_len..]
366    )
367}
368
369/// What an endpoint prints as wherever drep shows one to a person or a log.
370const REDACTED: &str = "<redacted>";
371
372/// An endpoint as drep prints it: without the credential a URL can carry.
373///
374/// An endpoint's `user:password@` is a credential, not an address. reqwest
375/// removes it from the URL and sends it as HTTP Basic auth, so a gateway behind
376/// a password is configured as `https://drep:${GATEWAY_PASSWORD}@host/v1`, and
377/// by the time a report prints the loaded config the variable holds the secret.
378/// Every place an endpoint is shown - `check`'s failover block and JSON,
379/// `doctor`, `init`, `auth`, and `Debug` - goes through here, and prints
380/// `https://<redacted>@host/v1` instead: the credential's presence stays
381/// visible, its value does not.
382///
383/// The URL is read with the parser reqwest itself uses, rather than by finding
384/// an `@`, so this strips what would be sent: `http://evil.example\@host/v1`
385/// names the host `evil.example` and carries no credential, and is printed as
386/// written. An endpoint without one is returned untouched, spelling and all.
387/// One that does not parse, or parses with no host (a scheme-less
388/// `user:pass@localhost:1234/v1` reads as an opaque path), cannot be split
389/// reliably, so it prints as `<redacted>` whole when it contains an `@`.
390///
391/// Display only. The cache identity keeps the raw endpoint, so rotating a
392/// password still starts the cache cold.
393pub fn display_endpoint(endpoint: &str) -> Cow<'_, str> {
394    let unsplittable = || {
395        Cow::Borrowed(if endpoint.contains('@') {
396            REDACTED
397        } else {
398            endpoint
399        })
400    };
401    let Ok(mut url) = reqwest::Url::parse(endpoint) else {
402        return unsplittable();
403    };
404    if url.username().is_empty() && url.password().is_none() {
405        return if url.has_host() {
406            Cow::Borrowed(endpoint)
407        } else {
408            unsplittable()
409        };
410    }
411    // Cannot fail: only a URL with no host, or a `file:` one, refuses a
412    // username, and neither parses with a credential in the first place.
413    if url.set_username("").and(url.set_password(None)).is_err() {
414        return Cow::Borrowed(REDACTED);
415    }
416    match url.as_str().split_once("://") {
417        Some((scheme, rest)) => Cow::Owned(format!("{scheme}://{REDACTED}@{rest}")),
418        None => Cow::Borrowed(REDACTED),
419    }
420}
421
422/// Fill in keys the config left unset, and report where each one came from.
423///
424/// Returns one [`KeySource`] per entry in `config.llm`, positionally - including
425/// the disabled ones, so the position in the returned slice is the position in the
426/// file. No production caller reads it: `check` discards it, and `doctor` calls
427/// [`source_of`] against the raw TOML tree instead, because `config::load` fails
428/// on an unset `${VAR}` and that is exactly the config `doctor` is most useful on.
429/// It is retained for the tests, which assert the precedence rule per entry
430/// against the one function that decides it, and the positional shape is what lets
431/// them name an entry by the line the user wrote.
432///
433/// **Disabled entries are skipped**, matching every other pass over the provider
434/// list: `${VAR}` expansion and field validation already leave a parked entry
435/// alone, and looking a key up for one would report a missing credential for a
436/// provider that is never contacted. For an `api_key_command` that also means no
437/// subprocess: some helpers are rate-limited and some prompt for a fingerprint,
438/// so spending a real credential call on a provider drep will not contact is
439/// worse than the missing-key report it avoids.
440///
441/// This is the one pass where a credential command runs, so each entry's command
442/// runs exactly once per process however many files the run reviews. A
443/// short-lived credential re-minted per file would fail per file, which the
444/// chain's demotion logic reads as an endpoint problem. There is deliberately no
445/// disk cache and no TTL behind that: drep is a short-lived process, so a
446/// credential written to disk buys nothing and adds a file worth stealing.
447///
448/// Once per process for every *enabled* entry, whether or not the chain reaches
449/// it. Deferring to first use would put credential resolution inside the request
450/// path, and "a broken credential is fatal rather than a provider drep quietly
451/// asks instead" holds precisely because resolution happens before the chain
452/// exists. An unset `${VAR}` in the same position is equally fatal, one layer up,
453/// in `ConfigError::EnvVarUnset`. `enabled = false` is the control for a fallback
454/// whose helper should not be spent.
455pub async fn resolve(config: &mut Config, store: &AuthStore) -> Result<Vec<KeySource>, AuthError> {
456    let mut sources = Vec::with_capacity(config.llm.len());
457    for (index, llm) in config.llm.iter_mut().enumerate() {
458        let source = source_of(
459            Declared {
460                api_key: llm.api_key.as_deref(),
461                has_api_key_command: llm.api_key_command.is_some(),
462                endpoint: llm.endpoint.as_deref(),
463                enabled: llm.enabled,
464            },
465            store,
466        );
467        match source {
468            // `source_of` returns `Command` only when the argv is present, so
469            // the default here is unreachable through it; `command::run` reports
470            // an empty argv rather than panicking if it ever becomes reachable.
471            KeySource::Command => {
472                let argv = llm.api_key_command.as_deref().unwrap_or_default();
473                let key = command::run_bounded(argv)
474                    .await
475                    .map_err(|cause| AuthError::KeyCommand { index, cause })?;
476                llm.api_key = Some(key);
477            }
478            KeySource::Store => {
479                if let Some(endpoint) = llm.endpoint.as_deref()
480                    && let Some(key) = store.get(endpoint)
481                {
482                    llm.api_key = Some(key.to_string());
483                }
484            }
485            KeySource::Config | KeySource::Missing => {}
486        }
487        sources.push(source);
488    }
489    Ok(sources)
490}
491
492/// Run one entry's `api_key_command` and discard the credential.
493///
494/// For `doctor`, whose contract is "what will actually run here": it has to
495/// really invoke the helper, because a helper that no longer authenticates is
496/// precisely what `api_key_command` exists to make visible. Returning `()`
497/// rather than the key is what makes printing it impossible at the call site,
498/// instead of a rule the reporting code has to remember.
499pub async fn probe_key_command(argv: &[String]) -> Result<(), KeyCommandError> {
500    command::run_bounded(argv).await.map(drop)
501}
502
503/// What one `[[llm]]` entry declares about its own credential.
504///
505/// Named fields rather than positional arguments, following `ExplicitFields`:
506/// `check` fills these from a loaded `Config` and `doctor` from the raw TOML
507/// tree, and two `Option<&str>` plus two `bool` in a call is a swap waiting to
508/// happen that the compiler would not catch.
509pub struct Declared<'a> {
510    pub api_key: Option<&'a str>,
511    pub has_api_key_command: bool,
512    pub endpoint: Option<&'a str>,
513    pub enabled: bool,
514}
515
516/// Where a provider's key will come from, given what its config names.
517///
518/// The precedence rule itself, in one place. [`resolve`] applies it to a loaded
519/// `Config`; `doctor` applies it to the *raw* TOML tree, which it has to read
520/// separately so a `${VAR}` prints as itself rather than being swallowed by the
521/// variable-not-set error. Two readers of one rule is the shape
522/// `config::env_var_refs_in` already exists to prevent: doctor once carried a
523/// narrower copy of that scanner and reported a config as fine that `check`
524/// refused to load.
525pub fn source_of(declared: Declared<'_>, store: &AuthStore) -> KeySource {
526    if !declared.enabled {
527        return KeySource::Missing;
528    }
529    if declared.api_key.is_some() {
530        return KeySource::Config;
531    }
532    if declared.has_api_key_command {
533        return KeySource::Command;
534    }
535    match declared.endpoint {
536        Some(endpoint) if store.get(endpoint).is_some() => KeySource::Store,
537        _ => KeySource::Missing,
538    }
539}
540
541#[cfg(test)]
542mod tests;