Skip to main content

uv_auth/
keyring.rs

1use std::{io::Write, process::Stdio};
2use tokio::process::Command;
3use tracing::{debug, instrument, trace, warn};
4use uv_redacted::DisplaySafeUrl;
5use uv_warnings::{warn_user_once, warn_user_once_with_chain};
6
7use crate::credentials::Credentials;
8
9/// Service name prefix for storing credentials in a keyring.
10static UV_SERVICE_PREFIX: &str = "uv:";
11
12/// A backend for retrieving credentials from a keyring.
13///
14/// See pip's implementation for reference
15/// <https://github.com/pypa/pip/blob/ae5fff36b0aad6e5e0037884927eaa29163c0611/src/pip/_internal/network/auth.py#L102>
16#[derive(Debug)]
17pub struct KeyringProvider {
18    backend: KeyringProviderBackend,
19}
20
21#[derive(thiserror::Error, Debug)]
22pub enum Error {
23    #[error(transparent)]
24    Keyring(#[from] uv_keyring::Error),
25
26    #[error("The '{0}' keyring provider does not support storing credentials")]
27    StoreUnsupported(&'static str),
28
29    #[error("The '{0}' keyring provider does not support removing credentials")]
30    RemoveUnsupported(&'static str),
31}
32
33#[derive(Debug)]
34enum KeyringProviderBackend {
35    /// Use a native system keyring integration for credentials.
36    Native,
37    /// Use the external `keyring` command for credentials.
38    Subprocess,
39    #[cfg(test)]
40    Dummy(Vec<(String, &'static str, &'static str)>),
41}
42
43impl KeyringProviderBackend {
44    fn name(&self) -> &'static str {
45        match self {
46            Self::Native => "native",
47            Self::Subprocess => "subprocess",
48            #[cfg(test)]
49            Self::Dummy(_) => "dummy",
50        }
51    }
52}
53
54impl std::fmt::Display for KeyringProviderBackend {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        f.write_str(self.name())
57    }
58}
59
60impl KeyringProvider {
61    /// Create a new [`KeyringProvider::Native`].
62    pub(crate) fn native() -> Self {
63        Self {
64            backend: KeyringProviderBackend::Native,
65        }
66    }
67
68    /// Create a new [`KeyringProvider::Subprocess`].
69    pub fn subprocess() -> Self {
70        Self {
71            backend: KeyringProviderBackend::Subprocess,
72        }
73    }
74
75    /// Store credentials for the given [`DisplaySafeUrl`] to the keyring.
76    ///
77    /// Only the native keyring provider is supported at this time.
78    #[instrument(skip_all, fields(url = % url.to_string(), username))]
79    pub async fn store(
80        &self,
81        url: &DisplaySafeUrl,
82        credentials: &Credentials,
83    ) -> Result<bool, Error> {
84        let Some(username) = credentials.username() else {
85            trace!("Unable to store credentials in keyring for `{url}` due to missing username");
86            return Ok(false);
87        };
88        let Some(password) = credentials.password() else {
89            trace!("Unable to store credentials in keyring for `{url}` due to missing password");
90            return Ok(false);
91        };
92
93        // Ensure we strip credentials from the URL before storing
94        let url = url.without_credentials();
95
96        // If there's no path, we'll perform a host-level login
97        let target = if let Some(host) = url.host_str().filter(|_| !url.path().is_empty()) {
98            let mut target = String::new();
99            if url.scheme() != "https" {
100                target.push_str(url.scheme());
101                target.push_str("://");
102            }
103            target.push_str(host);
104            if let Some(port) = url.port() {
105                target.push(':');
106                target.push_str(&port.to_string());
107            }
108            target
109        } else {
110            url.to_string()
111        };
112
113        match &self.backend {
114            KeyringProviderBackend::Native => {
115                self.store_native(&target, username, password).await?;
116                Ok(true)
117            }
118            KeyringProviderBackend::Subprocess => Err(Error::StoreUnsupported(self.backend.name())),
119            #[cfg(test)]
120            KeyringProviderBackend::Dummy(_) => Err(Error::StoreUnsupported(self.backend.name())),
121        }
122    }
123
124    /// Store credentials to the system keyring.
125    #[instrument(skip_all, fields(service = ?service, username = ?username))]
126    async fn store_native(
127        &self,
128        service: &str,
129        username: &str,
130        password: &str,
131    ) -> Result<(), Error> {
132        let prefixed_service = format!("{UV_SERVICE_PREFIX}{service}");
133        let entry = uv_keyring::Entry::new(&prefixed_service, username)?;
134        entry.set_password(password).await?;
135        Ok(())
136    }
137
138    /// Remove credentials for the given [`DisplaySafeUrl`] and username from the keyring.
139    ///
140    /// Only the native keyring provider is supported at this time.
141    #[instrument(skip_all, fields(url = % url.to_string(), username))]
142    pub async fn remove(&self, url: &DisplaySafeUrl, username: &str) -> Result<(), Error> {
143        // Ensure we strip credentials from the URL before storing
144        let url = url.without_credentials();
145
146        // If there's no path, we'll perform a host-level login
147        let target = if let Some(host) = url.host_str().filter(|_| !url.path().is_empty()) {
148            let mut target = String::new();
149            if url.scheme() != "https" {
150                target.push_str(url.scheme());
151                target.push_str("://");
152            }
153            target.push_str(host);
154            if let Some(port) = url.port() {
155                target.push(':');
156                target.push_str(&port.to_string());
157            }
158            target
159        } else {
160            url.to_string()
161        };
162
163        match &self.backend {
164            KeyringProviderBackend::Native => {
165                self.remove_native(&target, username).await?;
166                Ok(())
167            }
168            KeyringProviderBackend::Subprocess => {
169                Err(Error::RemoveUnsupported(self.backend.name()))
170            }
171            #[cfg(test)]
172            KeyringProviderBackend::Dummy(_) => Err(Error::RemoveUnsupported(self.backend.name())),
173        }
174    }
175
176    /// Remove credentials from the system keyring for the given `service_name`/`username`
177    /// pair.
178    #[instrument(skip(self))]
179    async fn remove_native(
180        &self,
181        service_name: &str,
182        username: &str,
183    ) -> Result<(), uv_keyring::Error> {
184        let prefixed_service = format!("{UV_SERVICE_PREFIX}{service_name}");
185        let entry = uv_keyring::Entry::new(&prefixed_service, username)?;
186        entry.delete_credential().await?;
187        trace!("Removed credentials for {username}@{service_name} from system keyring");
188        Ok(())
189    }
190
191    /// Fetch credentials for the given [`Url`] from the keyring.
192    ///
193    /// Returns [`None`] if no password was found for the username or if any errors
194    /// are encountered in the keyring backend.
195    #[instrument(skip_all, fields(url = % url.to_string(), username))]
196    pub async fn fetch(&self, url: &DisplaySafeUrl, username: Option<&str>) -> Option<Credentials> {
197        // Validate the request
198        debug_assert!(
199            url.host_str().is_some(),
200            "Should only use keyring for URLs with host"
201        );
202        debug_assert!(
203            url.password().is_none(),
204            "Should only use keyring for URLs without a password"
205        );
206        debug_assert!(
207            username.is_none_or(|username| !username.is_empty()),
208            "Should only use keyring with a non-empty username"
209        );
210
211        // Check the full URL first
212        // <https://github.com/pypa/pip/blob/ae5fff36b0aad6e5e0037884927eaa29163c0611/src/pip/_internal/network/auth.py#L376C1-L379C14>
213        trace!("Checking keyring for URL `{url}`");
214        let mut credentials = match self.backend {
215            KeyringProviderBackend::Native => self.fetch_native(url.as_str(), username).await,
216            KeyringProviderBackend::Subprocess => {
217                self.fetch_subprocess(url.as_str(), username).await
218            }
219            #[cfg(test)]
220            KeyringProviderBackend::Dummy(ref store) => {
221                Self::fetch_dummy(store, url.as_str(), username)
222            }
223        };
224        // And fallback to a check for the host
225        if credentials.is_none() {
226            let host = if let Some(port) = url.port() {
227                format!("{}:{}", url.host_str()?, port)
228            } else {
229                url.host_str()?.to_string()
230            };
231            trace!("Checking keyring for host {host}");
232            credentials = match self.backend {
233                KeyringProviderBackend::Native => self.fetch_native(&host, username).await,
234                KeyringProviderBackend::Subprocess => self.fetch_subprocess(&host, username).await,
235                #[cfg(test)]
236                KeyringProviderBackend::Dummy(ref store) => {
237                    Self::fetch_dummy(store, &host, username)
238                }
239            };
240
241            // For non-HTTPS URLs, `store` includes the scheme in the service name
242            // (e.g., `http://host:port`) to avoid leaking credentials across schemes.
243            // Try `scheme://host:port` as a fallback to match those entries.
244            if credentials.is_none() && url.scheme() != "https" {
245                let scheme_host = format!("{}://{host}", url.scheme());
246                trace!("Checking keyring for scheme+host {scheme_host}");
247                credentials = match self.backend {
248                    KeyringProviderBackend::Native => {
249                        self.fetch_native(&scheme_host, username).await
250                    }
251                    KeyringProviderBackend::Subprocess => {
252                        self.fetch_subprocess(&scheme_host, username).await
253                    }
254                    #[cfg(test)]
255                    KeyringProviderBackend::Dummy(ref store) => {
256                        Self::fetch_dummy(store, &scheme_host, username)
257                    }
258                };
259            }
260        }
261
262        credentials.map(|(username, password)| Credentials::basic(Some(username), Some(password)))
263    }
264
265    #[instrument(skip(self))]
266    async fn fetch_subprocess(
267        &self,
268        service_name: &str,
269        username: Option<&str>,
270    ) -> Option<(String, String)> {
271        // https://github.com/pypa/pip/blob/24.0/src/pip/_internal/network/auth.py#L136-L141
272        let mut command = Command::new("keyring");
273        command.arg("get").arg(service_name);
274
275        if let Some(username) = username {
276            command.arg(username);
277        } else {
278            command.arg("--mode").arg("creds");
279        }
280
281        let child = command
282            .stdin(Stdio::null())
283            .stdout(Stdio::piped())
284            // If we're using `--mode creds`, we need to capture the output in order to avoid
285            // showing users an "unrecognized arguments: --mode" error; otherwise, we stream stderr
286            // so the user has visibility into keyring's behavior if it's doing something slow
287            .stderr(if username.is_some() {
288                Stdio::inherit()
289            } else {
290                Stdio::piped()
291            })
292            .spawn()
293            .inspect_err(|err| warn!("Failure running `keyring` command: {err}"))
294            .ok()?;
295
296        let output = child
297            .wait_with_output()
298            .await
299            .inspect_err(|err| warn!("Failed to wait for `keyring` output: {err}"))
300            .ok()?;
301
302        if output.status.success() {
303            // If we captured stderr, display it in case it's helpful to the user
304            // TODO(zanieb): This was done when we added `--mode creds` support for parity with the
305            // existing behavior, but it might be a better UX to hide this on success? It also
306            // might be problematic that we're not streaming it. We could change this given some
307            // user feedback.
308            std::io::stderr().write_all(&output.stderr).ok();
309
310            // On success, parse the newline terminated credentials
311            let output = String::from_utf8(output.stdout)
312                .inspect_err(|err| warn!("Failed to parse response from `keyring` command: {err}"))
313                .ok()?;
314
315            let (username, password) = if let Some(username) = username {
316                // `keyring get` prints the password with a trailing newline.
317                let password = output
318                    .strip_suffix("\r\n")
319                    .or_else(|| output.strip_suffix('\n'))
320                    .unwrap_or(&output);
321                (username, password)
322            } else {
323                // We're expecting a username and password
324                let mut lines = output.lines();
325                let username = lines.next()?;
326                let Some(password) = lines.next() else {
327                    warn!(
328                        "Got username without password for `{service_name}` from `keyring` command"
329                    );
330                    return None;
331                };
332                (username, password)
333            };
334
335            if password.is_empty() {
336                // We allow this for backwards compatibility, but it might be better to return
337                // `None` instead if there's confusion from users — we haven't seen this in practice
338                // yet.
339                warn!("Got empty password for `{username}@{service_name}` from `keyring` command");
340            }
341
342            Some((username.to_string(), password.to_string()))
343        } else {
344            // On failure, no password was available
345            let stderr = std::str::from_utf8(&output.stderr).ok()?;
346            if stderr.contains("unrecognized arguments: --mode") {
347                // N.B. We do not show the `service_name` here because we'll show the warning twice
348                //      otherwise, once for the URL and once for the realm.
349                warn_user_once!(
350                    "Attempted to fetch credentials using the `keyring` command, but it does not support `--mode creds`; upgrade to `keyring>=v25.2.1` or provide a username"
351                );
352            } else if username.is_none() {
353                // If we captured stderr, display it in case it's helpful to the user
354                std::io::stderr().write_all(&output.stderr).ok();
355            }
356            None
357        }
358    }
359
360    #[instrument(skip(self))]
361    async fn fetch_native(
362        &self,
363        service: &str,
364        username: Option<&str>,
365    ) -> Option<(String, String)> {
366        let prefixed_service = format!("{UV_SERVICE_PREFIX}{service}");
367        let username = username?;
368        let Ok(entry) = uv_keyring::Entry::new(&prefixed_service, username) else {
369            return None;
370        };
371        match entry.get_password().await {
372            Ok(password) => return Some((username.to_string(), password)),
373            Err(uv_keyring::Error::NoEntry) => {
374                debug!("No entry found in system keyring for {service}");
375            }
376            Err(err) => {
377                warn_user_once_with_chain!(
378                    anyhow::Error::from(err)
379                        .context(format!(
380                            "Unable to fetch credentials for {service} from system keyring"
381                        ))
382                        .as_ref()
383                );
384            }
385        }
386        None
387    }
388
389    #[cfg(test)]
390    fn fetch_dummy(
391        store: &Vec<(String, &'static str, &'static str)>,
392        service_name: &str,
393        username: Option<&str>,
394    ) -> Option<(String, String)> {
395        store.iter().find_map(|(service, user, password)| {
396            if service == service_name && username.is_none_or(|username| username == *user) {
397                Some(((*user).to_string(), (*password).to_string()))
398            } else {
399                None
400            }
401        })
402    }
403
404    /// Create a new provider with [`KeyringProviderBackend::Dummy`].
405    #[cfg(test)]
406    pub(crate) fn dummy<
407        S: Into<String>,
408        T: IntoIterator<Item = (S, &'static str, &'static str)>,
409    >(
410        iter: T,
411    ) -> Self {
412        Self {
413            backend: KeyringProviderBackend::Dummy(
414                iter.into_iter()
415                    .map(|(service, username, password)| (service.into(), username, password))
416                    .collect(),
417            ),
418        }
419    }
420
421    /// Create a new provider with no credentials available.
422    #[cfg(test)]
423    fn empty() -> Self {
424        Self {
425            backend: KeyringProviderBackend::Dummy(Vec::new()),
426        }
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use url::Url;
434
435    #[tokio::test]
436    #[cfg_attr(
437        debug_assertions,
438        should_panic(expected = "Should only use keyring for URLs with host")
439    )]
440    async fn fetch_url_no_host() {
441        let url = Url::parse("file:/etc/bin/").unwrap();
442        let keyring = KeyringProvider::empty();
443        // Panics due to debug assertion; returns `None` in production
444        assert_eq!(
445            keyring
446                .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
447                .await,
448            None
449        );
450    }
451
452    #[tokio::test]
453    #[cfg_attr(
454        debug_assertions,
455        should_panic(expected = "Should only use keyring for URLs without a password")
456    )]
457    async fn fetch_url_with_password() {
458        let url = Url::parse("https://user:password@example.com").unwrap();
459        let keyring = KeyringProvider::empty();
460        // Panics due to debug assertion; returns `None` in production
461        assert_eq!(
462            keyring
463                .fetch(DisplaySafeUrl::ref_cast(&url), Some(url.username()))
464                .await,
465            None
466        );
467    }
468
469    #[tokio::test]
470    #[cfg_attr(
471        debug_assertions,
472        should_panic(expected = "Should only use keyring with a non-empty username")
473    )]
474    async fn fetch_url_with_empty_username() {
475        let url = Url::parse("https://example.com").unwrap();
476        let keyring = KeyringProvider::empty();
477        // Panics due to debug assertion; returns `None` in production
478        assert_eq!(
479            keyring
480                .fetch(DisplaySafeUrl::ref_cast(&url), Some(url.username()))
481                .await,
482            None
483        );
484    }
485
486    #[tokio::test]
487    async fn fetch_url_no_auth() {
488        let url = Url::parse("https://example.com").unwrap();
489        let url = DisplaySafeUrl::ref_cast(&url);
490        let keyring = KeyringProvider::empty();
491        let credentials = keyring.fetch(url, Some("user"));
492        assert!(credentials.await.is_none());
493    }
494
495    #[tokio::test]
496    async fn fetch_url() {
497        let url = Url::parse("https://example.com").unwrap();
498        let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
499        assert_eq!(
500            keyring
501                .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
502                .await,
503            Some(Credentials::basic(
504                Some("user".to_string()),
505                Some("password".to_string())
506            ))
507        );
508        assert_eq!(
509            keyring
510                .fetch(
511                    DisplaySafeUrl::ref_cast(&url.join("test").unwrap()),
512                    Some("user")
513                )
514                .await,
515            Some(Credentials::basic(
516                Some("user".to_string()),
517                Some("password".to_string())
518            ))
519        );
520    }
521
522    #[tokio::test]
523    async fn fetch_url_no_match() {
524        let url = Url::parse("https://example.com").unwrap();
525        let keyring = KeyringProvider::dummy([("other.com", "user", "password")]);
526        let credentials = keyring
527            .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
528            .await;
529        assert_eq!(credentials, None);
530    }
531
532    #[tokio::test]
533    async fn fetch_url_prefers_url_to_host() {
534        let url = Url::parse("https://example.com/").unwrap();
535        let keyring = KeyringProvider::dummy([
536            (url.join("foo").unwrap().as_str(), "user", "password"),
537            (url.host_str().unwrap(), "user", "other-password"),
538        ]);
539        assert_eq!(
540            keyring
541                .fetch(
542                    DisplaySafeUrl::ref_cast(&url.join("foo").unwrap()),
543                    Some("user")
544                )
545                .await,
546            Some(Credentials::basic(
547                Some("user".to_string()),
548                Some("password".to_string())
549            ))
550        );
551        assert_eq!(
552            keyring
553                .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
554                .await,
555            Some(Credentials::basic(
556                Some("user".to_string()),
557                Some("other-password".to_string())
558            ))
559        );
560        assert_eq!(
561            keyring
562                .fetch(
563                    DisplaySafeUrl::ref_cast(&url.join("bar").unwrap()),
564                    Some("user")
565                )
566                .await,
567            Some(Credentials::basic(
568                Some("user".to_string()),
569                Some("other-password".to_string())
570            ))
571        );
572    }
573
574    #[tokio::test]
575    async fn fetch_url_username() {
576        let url = Url::parse("https://example.com").unwrap();
577        let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
578        let credentials = keyring
579            .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
580            .await;
581        assert_eq!(
582            credentials,
583            Some(Credentials::basic(
584                Some("user".to_string()),
585                Some("password".to_string())
586            ))
587        );
588    }
589
590    #[tokio::test]
591    async fn fetch_url_no_username() {
592        let url = Url::parse("https://example.com").unwrap();
593        let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]);
594        let credentials = keyring.fetch(DisplaySafeUrl::ref_cast(&url), None).await;
595        assert_eq!(
596            credentials,
597            Some(Credentials::basic(
598                Some("user".to_string()),
599                Some("password".to_string())
600            ))
601        );
602    }
603
604    #[tokio::test]
605    async fn fetch_url_username_no_match() {
606        let url = Url::parse("https://example.com").unwrap();
607        let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "foo", "password")]);
608        let credentials = keyring
609            .fetch(DisplaySafeUrl::ref_cast(&url), Some("bar"))
610            .await;
611        assert_eq!(credentials, None);
612
613        // Still fails if we have `foo` in the URL itself
614        let url = Url::parse("https://foo@example.com").unwrap();
615        let credentials = keyring
616            .fetch(DisplaySafeUrl::ref_cast(&url), Some("bar"))
617            .await;
618        assert_eq!(credentials, None);
619    }
620
621    #[tokio::test]
622    async fn fetch_http_scheme_host_fallback() {
623        // When credentials are stored with scheme included (e.g., `http://host:port`),
624        // the fetch should find them via the `scheme://host:port` fallback.
625        let url = Url::parse("http://127.0.0.1:8080/basic-auth/simple/anyio/").unwrap();
626        let keyring = KeyringProvider::dummy([("http://127.0.0.1:8080", "user", "password")]);
627        let credentials = keyring
628            .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
629            .await;
630        assert_eq!(
631            credentials,
632            Some(Credentials::basic(
633                Some("user".to_string()),
634                Some("password".to_string())
635            ))
636        );
637    }
638
639    #[tokio::test]
640    async fn fetch_http_scheme_host_no_cross_scheme() {
641        // Credentials stored under `http://` should not be returned for `https://` requests.
642        let url = Url::parse("https://127.0.0.1:8080/basic-auth/simple/anyio/").unwrap();
643        let keyring = KeyringProvider::dummy([("http://127.0.0.1:8080", "user", "password")]);
644        let credentials = keyring
645            .fetch(DisplaySafeUrl::ref_cast(&url), Some("user"))
646            .await;
647        assert_eq!(credentials, None);
648    }
649}