uv_auth/
store.rs

1use std::ops::Deref;
2use std::path::{Path, PathBuf};
3
4use fs_err as fs;
5use rustc_hash::FxHashMap;
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8use uv_fs::{LockedFile, with_added_extension};
9use uv_preview::{Preview, PreviewFeatures};
10use uv_redacted::DisplaySafeUrl;
11
12use uv_state::{StateBucket, StateStore};
13use uv_static::EnvVars;
14
15use crate::credentials::{Password, Token, Username};
16use crate::realm::Realm;
17use crate::service::Service;
18use crate::{Credentials, KeyringProvider};
19
20/// The storage backend to use in `uv auth` commands.
21#[derive(Debug)]
22pub enum AuthBackend {
23    // TODO(zanieb): Right now, we're using a keyring provider for the system store but that's just
24    // where the native implementation is living at the moment. We should consider refactoring these
25    // into a shared API in the future.
26    System(KeyringProvider),
27    TextStore(TextCredentialStore, LockedFile),
28}
29
30impl AuthBackend {
31    pub fn from_settings(preview: Preview) -> Result<Self, TomlCredentialError> {
32        // If preview is enabled, we'll use the system-native store
33        if preview.is_enabled(PreviewFeatures::NATIVE_AUTH) {
34            return Ok(Self::System(KeyringProvider::native()));
35        }
36
37        // Otherwise, we'll use the plaintext credential store
38        let path = TextCredentialStore::default_file()?;
39        match TextCredentialStore::read(&path) {
40            Ok((store, lock)) => Ok(Self::TextStore(store, lock)),
41            Err(TomlCredentialError::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {
42                Ok(Self::TextStore(
43                    TextCredentialStore::default(),
44                    TextCredentialStore::lock(&path)?,
45                ))
46            }
47            Err(err) => Err(err),
48        }
49    }
50}
51
52/// Authentication scheme to use.
53#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "lowercase")]
55pub enum AuthScheme {
56    /// HTTP Basic Authentication
57    ///
58    /// Uses a username and password.
59    #[default]
60    Basic,
61    /// Bearer token authentication.
62    ///
63    /// Uses a token provided as `Bearer <token>` in the `Authorization` header.
64    Bearer,
65}
66
67/// Errors that can occur when working with TOML credential storage.
68#[derive(Debug, Error)]
69pub enum TomlCredentialError {
70    #[error(transparent)]
71    Io(#[from] std::io::Error),
72    #[error("Failed to parse TOML credential file: {0}")]
73    ParseError(#[from] toml::de::Error),
74    #[error("Failed to serialize credentials to TOML")]
75    SerializeError(#[from] toml::ser::Error),
76    #[error(transparent)]
77    BasicAuthError(#[from] BasicAuthError),
78    #[error(transparent)]
79    BearerAuthError(#[from] BearerAuthError),
80    #[error("Failed to determine credentials directory")]
81    CredentialsDirError,
82    #[error("Token is not valid unicode")]
83    TokenNotUnicode(#[from] std::string::FromUtf8Error),
84}
85
86#[derive(Debug, Error)]
87pub enum BasicAuthError {
88    #[error("`username` is required with `scheme = basic`")]
89    MissingUsername,
90    #[error("`token` cannot be provided with `scheme = basic`")]
91    UnexpectedToken,
92}
93
94#[derive(Debug, Error)]
95pub enum BearerAuthError {
96    #[error("`token` is required with `scheme = bearer`")]
97    MissingToken,
98    #[error("`username` cannot be provided with `scheme = bearer`")]
99    UnexpectedUsername,
100    #[error("`password` cannot be provided with `scheme = bearer`")]
101    UnexpectedPassword,
102}
103
104/// A single credential entry in a TOML credentials file.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106#[serde(try_from = "TomlCredentialWire", into = "TomlCredentialWire")]
107struct TomlCredential {
108    /// The service URL for this credential.
109    service: Service,
110    /// The credentials for this entry.
111    credentials: Credentials,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115struct TomlCredentialWire {
116    /// The service URL for this credential.
117    service: Service,
118    /// The username to use. Only allowed with [`AuthScheme::Basic`].
119    username: Username,
120    /// The authentication scheme.
121    #[serde(default)]
122    scheme: AuthScheme,
123    /// The password to use. Only allowed with [`AuthScheme::Basic`].
124    password: Option<Password>,
125    /// The token to use. Only allowed with [`AuthScheme::Bearer`].
126    token: Option<String>,
127}
128
129impl From<TomlCredential> for TomlCredentialWire {
130    fn from(value: TomlCredential) -> Self {
131        match value.credentials {
132            Credentials::Basic { username, password } => Self {
133                service: value.service,
134                username,
135                scheme: AuthScheme::Basic,
136                password,
137                token: None,
138            },
139            Credentials::Bearer { token } => Self {
140                service: value.service,
141                username: Username::new(None),
142                scheme: AuthScheme::Bearer,
143                password: None,
144                token: Some(String::from_utf8(token.into_bytes()).expect("Token is valid UTF-8")),
145            },
146        }
147    }
148}
149
150impl TryFrom<TomlCredentialWire> for TomlCredential {
151    type Error = TomlCredentialError;
152
153    fn try_from(value: TomlCredentialWire) -> Result<Self, Self::Error> {
154        match value.scheme {
155            AuthScheme::Basic => {
156                if value.username.as_deref().is_none() {
157                    return Err(TomlCredentialError::BasicAuthError(
158                        BasicAuthError::MissingUsername,
159                    ));
160                }
161                if value.token.is_some() {
162                    return Err(TomlCredentialError::BasicAuthError(
163                        BasicAuthError::UnexpectedToken,
164                    ));
165                }
166                let credentials = Credentials::Basic {
167                    username: value.username,
168                    password: value.password,
169                };
170                Ok(Self {
171                    service: value.service,
172                    credentials,
173                })
174            }
175            AuthScheme::Bearer => {
176                if value.username.is_some() {
177                    return Err(TomlCredentialError::BearerAuthError(
178                        BearerAuthError::UnexpectedUsername,
179                    ));
180                }
181                if value.password.is_some() {
182                    return Err(TomlCredentialError::BearerAuthError(
183                        BearerAuthError::UnexpectedPassword,
184                    ));
185                }
186                if value.token.is_none() {
187                    return Err(TomlCredentialError::BearerAuthError(
188                        BearerAuthError::MissingToken,
189                    ));
190                }
191                let credentials = Credentials::Bearer {
192                    token: Token::new(value.token.unwrap().into_bytes()),
193                };
194                Ok(Self {
195                    service: value.service,
196                    credentials,
197                })
198            }
199        }
200    }
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize, Default)]
204struct TomlCredentials {
205    /// Array of credential entries.
206    #[serde(rename = "credential")]
207    credentials: Vec<TomlCredential>,
208}
209
210/// A credential store with a plain text storage backend.
211#[derive(Debug, Default)]
212pub struct TextCredentialStore {
213    credentials: FxHashMap<(Service, Username), Credentials>,
214}
215
216impl TextCredentialStore {
217    /// Return the directory for storing credentials.
218    pub fn directory_path() -> Result<PathBuf, TomlCredentialError> {
219        if let Some(dir) = std::env::var_os(EnvVars::UV_CREDENTIALS_DIR)
220            .filter(|s| !s.is_empty())
221            .map(PathBuf::from)
222        {
223            return Ok(dir);
224        }
225
226        Ok(StateStore::from_settings(None)?.bucket(StateBucket::Credentials))
227    }
228
229    /// Return the standard file path for storing credentials.
230    pub fn default_file() -> Result<PathBuf, TomlCredentialError> {
231        let dir = Self::directory_path()?;
232        Ok(dir.join("credentials.toml"))
233    }
234
235    /// Acquire a lock on the credentials file at the given path.
236    pub fn lock(path: &Path) -> Result<LockedFile, TomlCredentialError> {
237        if let Some(parent) = path.parent() {
238            fs::create_dir_all(parent)?;
239        }
240        let lock = with_added_extension(path, ".lock");
241        Ok(LockedFile::acquire_blocking(lock, "credentials store")?)
242    }
243
244    /// Read credentials from a file.
245    fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, TomlCredentialError> {
246        let content = fs::read_to_string(path)?;
247        let credentials: TomlCredentials = toml::from_str(&content)?;
248
249        let credentials: FxHashMap<(Service, Username), Credentials> = credentials
250            .credentials
251            .into_iter()
252            .map(|credential| {
253                let username = match &credential.credentials {
254                    Credentials::Basic { username, .. } => username.clone(),
255                    Credentials::Bearer { .. } => Username::none(),
256                };
257                (
258                    (credential.service.clone(), username),
259                    credential.credentials,
260                )
261            })
262            .collect();
263
264        Ok(Self { credentials })
265    }
266
267    /// Read credentials from a file.
268    ///
269    /// Returns [`TextCredentialStore`] and a [`LockedFile`] to hold if mutating the store.
270    ///
271    /// If the store will not be written to following the read, the lock can be dropped.
272    pub fn read<P: AsRef<Path>>(path: P) -> Result<(Self, LockedFile), TomlCredentialError> {
273        let lock = Self::lock(path.as_ref())?;
274        let store = Self::from_file(path)?;
275        Ok((store, lock))
276    }
277
278    /// Persist credentials to a file.
279    ///
280    /// Requires a [`LockedFile`] from [`TextCredentialStore::lock`] or
281    /// [`TextCredentialStore::read`] to ensure exclusive access.
282    pub fn write<P: AsRef<Path>>(
283        self,
284        path: P,
285        _lock: LockedFile,
286    ) -> Result<(), TomlCredentialError> {
287        let credentials = self
288            .credentials
289            .into_iter()
290            .map(|((service, _username), credentials)| TomlCredential {
291                service,
292                credentials,
293            })
294            .collect::<Vec<_>>();
295
296        let toml_creds = TomlCredentials { credentials };
297        let content = toml::to_string_pretty(&toml_creds)?;
298        fs::create_dir_all(
299            path.as_ref()
300                .parent()
301                .ok_or(TomlCredentialError::CredentialsDirError)?,
302        )?;
303
304        // TODO(zanieb): We should use an atomic write here
305        fs::write(path, content)?;
306        Ok(())
307    }
308
309    /// Get credentials for a given URL and username.
310    ///
311    /// The most specific URL prefix match in the same [`Realm`] is returned, if any.
312    pub fn get_credentials(
313        &self,
314        url: &DisplaySafeUrl,
315        username: Option<&str>,
316    ) -> Option<&Credentials> {
317        let request_realm = Realm::from(url);
318
319        // Perform an exact lookup first
320        // TODO(zanieb): Consider adding `DisplaySafeUrlRef` so we can avoid this clone
321        // TODO(zanieb): We could also return early here if we can't normalize to a `Service`
322        if let Ok(url_service) = Service::try_from(url.clone()) {
323            if let Some(credential) = self.credentials.get(&(
324                url_service.clone(),
325                Username::from(username.map(str::to_string)),
326            )) {
327                return Some(credential);
328            }
329        }
330
331        // If that fails, iterate through to find a prefix match
332        let mut best: Option<(usize, &Service, &Credentials)> = None;
333
334        for ((service, stored_username), credential) in &self.credentials {
335            let service_realm = Realm::from(service.url().deref());
336
337            // Only consider services in the same realm
338            if service_realm != request_realm {
339                continue;
340            }
341
342            // Service path must be a prefix of request path
343            if !url.path().starts_with(service.url().path()) {
344                continue;
345            }
346
347            // If a username is provided, it must match
348            if let Some(request_username) = username {
349                if Some(request_username) != stored_username.as_deref() {
350                    continue;
351                }
352            }
353
354            // Update our best matching credential based on prefix length
355            let specificity = service.url().path().len();
356            if best.is_none_or(|(best_specificity, _, _)| specificity > best_specificity) {
357                best = Some((specificity, service, credential));
358            }
359        }
360
361        // Return the most specific match
362        if let Some((_, _, credential)) = best {
363            return Some(credential);
364        }
365
366        None
367    }
368
369    /// Store credentials for a given service.
370    pub fn insert(&mut self, service: Service, credentials: Credentials) -> Option<Credentials> {
371        let username = match &credentials {
372            Credentials::Basic { username, .. } => username.clone(),
373            Credentials::Bearer { .. } => Username::none(),
374        };
375        self.credentials.insert((service, username), credentials)
376    }
377
378    /// Remove credentials for a given service.
379    pub fn remove(&mut self, service: &Service, username: Username) -> Option<Credentials> {
380        // Remove the specific credential for this service and username
381        self.credentials.remove(&(service.clone(), username))
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use std::io::Write;
388    use std::str::FromStr;
389
390    use tempfile::NamedTempFile;
391
392    use super::*;
393
394    #[test]
395    fn test_toml_serialization() {
396        let credentials = TomlCredentials {
397            credentials: vec![
398                TomlCredential {
399                    service: Service::from_str("https://example.com").unwrap(),
400                    credentials: Credentials::Basic {
401                        username: Username::new(Some("user1".to_string())),
402                        password: Some(Password::new("pass1".to_string())),
403                    },
404                },
405                TomlCredential {
406                    service: Service::from_str("https://test.org").unwrap(),
407                    credentials: Credentials::Basic {
408                        username: Username::new(Some("user2".to_string())),
409                        password: Some(Password::new("pass2".to_string())),
410                    },
411                },
412            ],
413        };
414
415        let toml_str = toml::to_string_pretty(&credentials).unwrap();
416        let parsed: TomlCredentials = toml::from_str(&toml_str).unwrap();
417
418        assert_eq!(parsed.credentials.len(), 2);
419        assert_eq!(
420            parsed.credentials[0].service.to_string(),
421            "https://example.com/"
422        );
423        assert_eq!(
424            parsed.credentials[1].service.to_string(),
425            "https://test.org/"
426        );
427    }
428
429    #[test]
430    fn test_credential_store_operations() {
431        let mut store = TextCredentialStore::default();
432        let credentials = Credentials::basic(Some("user".to_string()), Some("pass".to_string()));
433
434        let service = Service::from_str("https://example.com").unwrap();
435        store.insert(service.clone(), credentials.clone());
436        let url = DisplaySafeUrl::parse("https://example.com/").unwrap();
437        assert!(store.get_credentials(&url, None).is_some());
438
439        let url = DisplaySafeUrl::parse("https://example.com/path").unwrap();
440        let retrieved = store.get_credentials(&url, None).unwrap();
441        assert_eq!(retrieved.username(), Some("user"));
442        assert_eq!(retrieved.password(), Some("pass"));
443
444        assert!(
445            store
446                .remove(&service, Username::from(Some("user".to_string())))
447                .is_some()
448        );
449        let url = DisplaySafeUrl::parse("https://example.com/").unwrap();
450        assert!(store.get_credentials(&url, None).is_none());
451    }
452
453    #[test]
454    fn test_file_operations() {
455        let mut temp_file = NamedTempFile::new().unwrap();
456        writeln!(
457            temp_file,
458            r#"
459[[credential]]
460service = "https://example.com"
461username = "testuser"
462scheme = "basic"
463password = "testpass"
464
465[[credential]]
466service = "https://test.org"
467username = "user2"
468password = "pass2"
469"#
470        )
471        .unwrap();
472
473        let store = TextCredentialStore::from_file(temp_file.path()).unwrap();
474
475        let url = DisplaySafeUrl::parse("https://example.com/").unwrap();
476        assert!(store.get_credentials(&url, None).is_some());
477        let url = DisplaySafeUrl::parse("https://test.org/").unwrap();
478        assert!(store.get_credentials(&url, None).is_some());
479
480        let url = DisplaySafeUrl::parse("https://example.com").unwrap();
481        let cred = store.get_credentials(&url, None).unwrap();
482        assert_eq!(cred.username(), Some("testuser"));
483        assert_eq!(cred.password(), Some("testpass"));
484
485        // Test saving
486        let temp_output = NamedTempFile::new().unwrap();
487        store
488            .write(
489                temp_output.path(),
490                TextCredentialStore::lock(temp_file.path()).unwrap(),
491            )
492            .unwrap();
493
494        let content = fs::read_to_string(temp_output.path()).unwrap();
495        assert!(content.contains("example.com"));
496        assert!(content.contains("testuser"));
497    }
498
499    #[test]
500    fn test_prefix_matching() {
501        let mut store = TextCredentialStore::default();
502        let credentials = Credentials::basic(Some("user".to_string()), Some("pass".to_string()));
503
504        // Store credentials for a specific path prefix
505        let service = Service::from_str("https://example.com/api").unwrap();
506        store.insert(service.clone(), credentials.clone());
507
508        // Should match URLs that are prefixes of the stored service
509        let matching_urls = [
510            "https://example.com/api",
511            "https://example.com/api/v1",
512            "https://example.com/api/v1/users",
513        ];
514
515        for url_str in matching_urls {
516            let url = DisplaySafeUrl::parse(url_str).unwrap();
517            let cred = store.get_credentials(&url, None);
518            assert!(cred.is_some(), "Failed to match URL with prefix: {url_str}");
519        }
520
521        // Should NOT match URLs that are not prefixes
522        let non_matching_urls = [
523            "https://example.com/different",
524            "https://example.com/ap", // Not a complete path segment match
525            "https://example.com",    // Shorter than the stored prefix
526        ];
527
528        for url_str in non_matching_urls {
529            let url = DisplaySafeUrl::parse(url_str).unwrap();
530            let cred = store.get_credentials(&url, None);
531            assert!(cred.is_none(), "Should not match non-prefix URL: {url_str}");
532        }
533    }
534
535    #[test]
536    fn test_realm_based_matching() {
537        let mut store = TextCredentialStore::default();
538        let credentials = Credentials::basic(Some("user".to_string()), Some("pass".to_string()));
539
540        // Store by full URL (realm)
541        let service = Service::from_str("https://example.com").unwrap();
542        store.insert(service.clone(), credentials.clone());
543
544        // Should match URLs in the same realm
545        let matching_urls = [
546            "https://example.com",
547            "https://example.com/path",
548            "https://example.com/different/path",
549            "https://example.com:443/path", // Default HTTPS port
550        ];
551
552        for url_str in matching_urls {
553            let url = DisplaySafeUrl::parse(url_str).unwrap();
554            let cred = store.get_credentials(&url, None);
555            assert!(
556                cred.is_some(),
557                "Failed to match URL in same realm: {url_str}"
558            );
559        }
560
561        // Should NOT match URLs in different realms
562        let non_matching_urls = [
563            "http://example.com",       // Different scheme
564            "https://different.com",    // Different host
565            "https://example.com:8080", // Different port
566        ];
567
568        for url_str in non_matching_urls {
569            let url = DisplaySafeUrl::parse(url_str).unwrap();
570            let cred = store.get_credentials(&url, None);
571            assert!(
572                cred.is_none(),
573                "Should not match URL in different realm: {url_str}"
574            );
575        }
576    }
577
578    #[test]
579    fn test_most_specific_prefix_matching() {
580        let mut store = TextCredentialStore::default();
581        let general_cred =
582            Credentials::basic(Some("general".to_string()), Some("pass1".to_string()));
583        let specific_cred =
584            Credentials::basic(Some("specific".to_string()), Some("pass2".to_string()));
585
586        // Store credentials with different prefix lengths
587        let general_service = Service::from_str("https://example.com/api").unwrap();
588        let specific_service = Service::from_str("https://example.com/api/v1").unwrap();
589        store.insert(general_service.clone(), general_cred);
590        store.insert(specific_service.clone(), specific_cred);
591
592        // Should match the most specific prefix
593        let url = DisplaySafeUrl::parse("https://example.com/api/v1/users").unwrap();
594        let cred = store.get_credentials(&url, None).unwrap();
595        assert_eq!(cred.username(), Some("specific"));
596
597        // Should match the general prefix for non-specific paths
598        let url = DisplaySafeUrl::parse("https://example.com/api/v2").unwrap();
599        let cred = store.get_credentials(&url, None).unwrap();
600        assert_eq!(cred.username(), Some("general"));
601    }
602
603    #[test]
604    fn test_username_exact_url_match() {
605        let mut store = TextCredentialStore::default();
606        let url = DisplaySafeUrl::parse("https://example.com").unwrap();
607        let service = Service::from_str("https://example.com").unwrap();
608        let user1_creds = Credentials::basic(Some("user1".to_string()), Some("pass1".to_string()));
609        store.insert(service.clone(), user1_creds.clone());
610
611        // Should return credentials when username matches
612        let result = store.get_credentials(&url, Some("user1"));
613        assert!(result.is_some());
614        assert_eq!(result.unwrap().username(), Some("user1"));
615        assert_eq!(result.unwrap().password(), Some("pass1"));
616
617        // Should not return credentials when username doesn't match
618        let result = store.get_credentials(&url, Some("user2"));
619        assert!(result.is_none());
620
621        // Should return credentials when no username is specified
622        let result = store.get_credentials(&url, None);
623        assert!(result.is_some());
624        assert_eq!(result.unwrap().username(), Some("user1"));
625    }
626
627    #[test]
628    fn test_username_prefix_url_match() {
629        let mut store = TextCredentialStore::default();
630
631        // Add credentials with different usernames for overlapping URL prefixes
632        let general_service = Service::from_str("https://example.com/api").unwrap();
633        let specific_service = Service::from_str("https://example.com/api/v1").unwrap();
634
635        let general_creds = Credentials::basic(
636            Some("general_user".to_string()),
637            Some("general_pass".to_string()),
638        );
639        let specific_creds = Credentials::basic(
640            Some("specific_user".to_string()),
641            Some("specific_pass".to_string()),
642        );
643
644        store.insert(general_service, general_creds);
645        store.insert(specific_service, specific_creds);
646
647        let url = DisplaySafeUrl::parse("https://example.com/api/v1/users").unwrap();
648
649        // Should match specific credentials when username matches
650        let result = store.get_credentials(&url, Some("specific_user"));
651        assert!(result.is_some());
652        assert_eq!(result.unwrap().username(), Some("specific_user"));
653
654        // Should match the general credentials when requesting general_user (falls back to less specific prefix)
655        let result = store.get_credentials(&url, Some("general_user"));
656        assert!(
657            result.is_some(),
658            "Should match general_user from less specific prefix"
659        );
660        assert_eq!(result.unwrap().username(), Some("general_user"));
661
662        // Should match most specific when no username specified
663        let result = store.get_credentials(&url, None);
664        assert!(result.is_some());
665        assert_eq!(result.unwrap().username(), Some("specific_user"));
666    }
667}