diff --git a/Cargo.lock b/Cargo.lock index a940a2e0769..38f052c2b83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5857,6 +5857,7 @@ dependencies = [ "uv-install-operations", "uv-install-wheel", "uv-installer", + "uv-keyring", "uv-lock", "uv-lock-operations", "uv-logging", @@ -5968,7 +5969,9 @@ dependencies = [ "astral-reqwest-middleware", "async-trait", "base64 0.22.1", + "etcetera", "fs-err", + "hex", "http", "insta", "percent-encoding", @@ -5977,6 +5980,8 @@ dependencies = [ "rustc-hash", "schemars", "serde", + "serde_json", + "sha2 0.11.0", "tempfile", "test-log", "thiserror", @@ -5984,6 +5989,7 @@ dependencies = [ "toml", "tracing", "url", + "uv-cache-key", "uv-fs", "uv-keyring", "uv-netrc", @@ -5995,6 +6001,7 @@ dependencies = [ "uv-static", "uv-warnings", "wiremock", + "zeroize", ] [[package]] diff --git a/crates/uv-auth/Cargo.toml b/crates/uv-auth/Cargo.toml index 706060251cb..03b6edb7bee 100644 --- a/crates/uv-auth/Cargo.toml +++ b/crates/uv-auth/Cargo.toml @@ -12,10 +12,14 @@ license = { workspace = true } [lib] doctest = false +[features] +native-auth = [] + [lints] workspace = true [dependencies] +uv-cache-key = { workspace = true } uv-fs = { workspace = true } uv-keyring = { workspace = true, features = ["apple-native", "secret-service", "windows-native"] } uv-netrc = { workspace = true } @@ -30,6 +34,7 @@ uv-warnings = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } +etcetera = { workspace = true } fs-err = { workspace = true, features = ["tokio"] } http = { workspace = true } percent-encoding = { workspace = true } @@ -39,11 +44,17 @@ reqwest-middleware = { workspace = true } rustc-hash = { workspace = true } schemars = { workspace = true, optional = true } serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } toml = { workspace = true } tracing = { workspace = true } url = { workspace = true } +zeroize = { workspace = true } + +[target.'cfg(windows)'.dependencies] +hex = { workspace = true } +sha2 = { workspace = true } [dev-dependencies] insta = { workspace = true } diff --git a/crates/uv-auth/src/cache.rs b/crates/uv-auth/src/cache.rs index e143282e79e..269ce3d5001 100644 --- a/crates/uv-auth/src/cache.rs +++ b/crates/uv-auth/src/cache.rs @@ -1,9 +1,10 @@ use std::fmt::{self, Display, Formatter}; +use std::future::Future; use std::hash::BuildHasherDefault; -use std::sync::Arc; -use std::sync::RwLock; +use std::sync::{Arc, PoisonError, RwLock}; use rustc_hash::{FxHashMap, FxHasher}; +use tokio::sync::{Mutex as AsyncMutex, MutexGuard, OnceCell}; use tracing::trace; use url::Url; @@ -11,33 +12,119 @@ use uv_once_map::OnceMap; use uv_redacted::DisplaySafeUrl; use crate::credentials::{Authentication, CredentialsFromUrlError, Username}; +use crate::matching::{self, AmbiguousCredential}; +use crate::persistent::PersistentCredential; use crate::{Credentials, Realm}; type FxOnceMap = OnceMap>; +type RealmCredentials = RwLock>>; +type NativeRealmCredentials = Result>; + +/// Shared realm contents retained by both pending requests and the authenticated cache. +#[derive(Debug, Clone)] +pub(crate) struct StoredCredentials(Arc); + +#[derive(Debug)] +struct StoredCredentialsInner { + credentials: RwLock>, + /// Serialize native lookups through publication of their updated realm contents. + native_lookup: AsyncMutex<()>, +} + +impl From> for StoredCredentials { + fn from(credentials: Vec) -> Self { + Self(Arc::new(StoredCredentialsInner { + credentials: RwLock::new(Arc::from(credentials)), + native_lookup: AsyncMutex::new(()), + })) + } +} + +impl StoredCredentials { + fn snapshot(&self) -> Arc<[PersistentCredential]> { + self.0 + .credentials + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + /// Serialize native migration and publication before another lookup can start. + pub(crate) async fn lock_native_lookup(&self) -> NativeLookup<'_> { + NativeLookup { + credentials: self, + _guard: self.0.native_lookup.lock().await, + } + } +} + +/// Exclusive permission to publish the result of a native lookup. +#[derive(Debug)] +pub(crate) struct NativeLookup<'a> { + credentials: &'a StoredCredentials, + _guard: MutexGuard<'a, ()>, +} + +impl NativeLookup<'_> { + pub(crate) fn replace(&self, credentials: Vec) { + *self + .credentials + .0 + .credentials + .write() + .unwrap_or_else(PoisonError::into_inner) = Arc::from(credentials); + } +} #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub(crate) enum FetchUrl { - /// A full index URL + /// A configured index URL. Index(DisplaySafeUrl), - /// A realm URL + /// An authentication realm. Realm(Realm), } impl Display for FetchUrl { - fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + fn fmt(&self, formatter: &mut Formatter) -> std::fmt::Result { match self { - Self::Index(index) => Display::fmt(index, f), - Self::Realm(realm) => Display::fmt(realm, f), + Self::Index(url) => Display::fmt(url, formatter), + Self::Realm(realm) => Display::fmt(realm, formatter), } } } +#[derive(Debug, Clone)] +pub(crate) enum CredentialsCacheScope { + /// Cache credentials for the entire realm and the request URL. + Realm, + /// Cache subprocess credentials as a fallback after path-scoped stores. + KeyringFallback, + /// Cache the complete stored credential snapshot and match by service path. + Stored(StoredCredentials), + /// Do not add credentials to the eager authentication cache. + FetchOnly, +} + +#[derive(Debug, Clone)] +pub(crate) struct FetchedCredentials { + pub(crate) credentials: Arc, + pub(crate) cache_scope: CredentialsCacheScope, +} + #[derive(Debug)] // All internal types are redacted. pub struct CredentialsCache { /// A cache per realm and username - realms: RwLock>>, - /// A cache tracking the result of realm or index URL fetches from external services - pub(crate) fetches: FxOnceMap<(FetchUrl, Username), Option>>, + realms: RealmCredentials, + /// Successfully authenticated subprocess credentials, consulted after path-scoped stores. + keyring_realms: RealmCredentials, + /// Cached realm- or index-scoped provider lookups. + pub(crate) fetches: FxOnceMap<(FetchUrl, Username), Option>, + /// Cached subprocess keyring lookups. + pub(crate) keyring_fetches: FxOnceMap<(FetchUrl, Username), Option>, + /// Native credential snapshots, loaded at most once per realm. + native_realms: RwLock>>>, + /// Successfully authenticated credential snapshots, matched by service path. + stored: RwLock>, /// A cache per URL, uses a trie for efficient prefix queries. urls: RwLock>>, } @@ -53,11 +140,36 @@ impl CredentialsCache { pub fn new() -> Self { Self { fetches: FxOnceMap::default(), + keyring_fetches: FxOnceMap::default(), + native_realms: RwLock::new(FxHashMap::default()), realms: RwLock::new(FxHashMap::default()), + keyring_realms: RwLock::new(FxHashMap::default()), + stored: RwLock::new(FxHashMap::default()), urls: RwLock::new(UrlTrie::new()), } } + /// Load and share native credentials for a realm across clients. + pub(crate) async fn native_realm( + &self, + realm: &Realm, + initialize: F, + ) -> NativeRealmCredentials + where + F: FnOnce() -> Fut, + Fut: Future, + { + let cell = self + .native_realms + .write() + .unwrap_or_else(PoisonError::into_inner) + .entry(realm.clone()) + .or_default() + .clone(); + // Cancelled native operations leave the cell available for a later initializer. + cell.get_or_init(initialize).await.clone() + } + /// Populate the global authentication store with credentials on a URL, if there are any. /// /// Returns `true` if the store was updated. @@ -88,7 +200,23 @@ impl CredentialsCache { realm: Realm, username: Username, ) -> Option> { - let realms = self.realms.read().unwrap(); + Self::get_realm_from(&self.realms, realm, username) + } + + pub(crate) fn get_keyring_realm( + &self, + realm: Realm, + username: Username, + ) -> Option> { + Self::get_realm_from(&self.keyring_realms, realm, username) + } + + fn get_realm_from( + cache: &RealmCredentials, + realm: Realm, + username: Username, + ) -> Option> { + let realms = cache.read().unwrap_or_else(PoisonError::into_inner); let given_username = username.is_some(); let key = (realm, username); let realm_username = fmt::from_fn(|f| { @@ -142,6 +270,61 @@ impl CredentialsCache { None } + /// Return the best credential from a successfully authenticated realm snapshot. + pub(crate) fn get_stored( + &self, + url: &DisplaySafeUrl, + username: &Username, + ) -> Result>, AmbiguousCredential> { + let Some(credentials) = self.get_stored_snapshot(&Realm::from(url)) else { + return Ok(None); + }; + Self::select_stored(&credentials, url, username) + } + + /// Borrow the shared contents of an authenticated realm snapshot. + pub(crate) fn get_stored_snapshot(&self, realm: &Realm) -> Option { + self.stored + .read() + .unwrap_or_else(PoisonError::into_inner) + .get(realm) + .cloned() + } + + /// Select the most specific service and account in a complete realm snapshot. + pub(crate) fn select_stored( + credentials: &StoredCredentials, + url: &DisplaySafeUrl, + username: &Username, + ) -> Result>, AmbiguousCredential> { + let credentials = credentials.snapshot(); + matching::select_credential( + credentials.iter().map(|credential| { + ( + &credential.service, + credential.credentials.username(), + &credential.credentials, + ) + }), + url, + username.as_deref(), + ) + .map(|credentials| credentials.cloned().map(Authentication::from).map(Arc::new)) + } + + /// Remember a complete realm snapshot after its credentials authenticate successfully. + pub(crate) fn insert_stored(&self, url: &DisplaySafeUrl, credentials: StoredCredentials) { + if credentials.snapshot().is_empty() { + return; + } + let realm = Realm::from(url); + trace!("Caching path-scoped credentials for realm {realm}"); + self.stored + .write() + .unwrap_or_else(PoisonError::into_inner) + .insert(realm, credentials); + } + /// Update the cache with the given credentials. pub(crate) fn insert(&self, url: &DisplaySafeUrl, credentials: Arc) { // Do not cache empty credentials @@ -153,22 +336,47 @@ impl CredentialsCache { let username = credentials.to_username(); if username.is_some() { let realm = (Realm::from(url), username); - self.insert_realm(realm, &credentials); + Self::insert_realm(&self.realms, realm, &credentials); } // Insert an entry for requests with no username - self.insert_realm((Realm::from(url), Username::none()), &credentials); + Self::insert_realm( + &self.realms, + (Realm::from(url), Username::none()), + &credentials, + ); - // Insert an entry for the URL + // Insert an entry for the URL. let mut urls = self.urls.write().unwrap(); urls.insert(url, credentials); } + /// Retain subprocess credentials without letting them mask path-scoped stores. + pub(crate) fn insert_keyring_realm( + &self, + url: &DisplaySafeUrl, + credentials: &Arc, + ) { + let username = credentials.to_username(); + if username.is_some() { + Self::insert_realm( + &self.keyring_realms, + (Realm::from(url), username), + credentials, + ); + } + Self::insert_realm( + &self.keyring_realms, + (Realm::from(url), Username::none()), + credentials, + ); + } + /// Private interface to update a realm cache entry. /// /// Returns replaced credentials, if any. fn insert_realm( - &self, + cache: &RealmCredentials, key: (Realm, Username), credentials: &Arc, ) -> Option> { @@ -177,7 +385,7 @@ impl CredentialsCache { return None; } - let mut realms = self.realms.write().unwrap(); + let mut realms = cache.write().unwrap_or_else(PoisonError::into_inner); // Always replace existing entries if we have a password or token if credentials.is_authenticated() { diff --git a/crates/uv-auth/src/credentials.rs b/crates/uv-auth/src/credentials.rs index 9204e1a8ed2..678a6f83689 100644 --- a/crates/uv-auth/src/credentials.rs +++ b/crates/uv-auth/src/credentials.rs @@ -163,7 +163,8 @@ impl Credentials { } } - fn to_username(&self) -> Username { + /// Return the username used to identify these credentials in persistent stores. + pub(crate) fn to_username(&self) -> Username { match self { Self::Basic { username, .. } => username.clone(), Self::Bearer { .. } => Username::none(), diff --git a/crates/uv-auth/src/keyring.rs b/crates/uv-auth/src/keyring.rs index 2d58b773948..f45985f14b1 100644 --- a/crates/uv-auth/src/keyring.rs +++ b/crates/uv-auth/src/keyring.rs @@ -1,13 +1,18 @@ use std::{io::Write, process::Stdio}; + use tokio::process::Command; -use tracing::{debug, instrument, trace, warn}; +use tracing::{instrument, trace, warn}; +use uv_fs::LockedFileError; use uv_redacted::DisplaySafeUrl; -use uv_warnings::{warn_user_once, warn_user_once_with_chain}; +use uv_warnings::warn_user_once; +use crate::Realm; use crate::credentials::Credentials; +use crate::persistent::PersistentCredential; +use crate::service::{Service, ServiceParseError}; -/// Service name prefix for storing credentials in a keyring. -static UV_SERVICE_PREFIX: &str = "uv:"; +mod native; +pub(crate) use native::fetch as fetch_native; /// A backend for retrieving credentials from a keyring. /// @@ -23,6 +28,33 @@ pub enum Error { #[error(transparent)] Keyring(#[from] uv_keyring::Error), + #[error("Stored credentials in the system keyring are corrupt")] + CorruptStoredCredentials(#[source] serde_json::Error), + + #[error("Failed to serialize credentials for the system keyring")] + SerializeStoredCredentials(#[source] serde_json::Error), + + #[error("Failed to prepare lock directory for native credential store")] + NativeLockDirectory(#[source] std::io::Error), + + #[error("Failed to acquire lock for native credential store")] + NativeLock(#[source] LockedFileError), + + #[error("Invalid service URL for native credential storage")] + InvalidService(#[source] ServiceParseError), + + #[error("Credential service does not match the locked realm")] + MismatchedRealm, + + #[error("Multiple credentials found for URL '{0}', specify which username to use")] + AmbiguousUsername(DisplaySafeUrl), + + #[error("Native credential storage requires a username")] + MissingUsername, + + #[error("Native credential storage requires a password")] + MissingPassword, + #[error("The '{0}' keyring provider does not support storing credentials")] StoreUnsupported(&'static str), @@ -52,119 +84,63 @@ impl KeyringProviderBackend { } impl std::fmt::Display for KeyringProviderBackend { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.name()) + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.name()) } } impl KeyringProvider { - /// Create a new [`KeyringProvider::Native`]. + /// Create a native system keyring provider. pub(crate) fn native() -> Self { Self { backend: KeyringProviderBackend::Native, } } - /// Create a new [`KeyringProvider::Subprocess`]. + /// Create a subprocess keyring provider. pub fn subprocess() -> Self { Self { backend: KeyringProviderBackend::Subprocess, } } - /// Store credentials for the given [`DisplaySafeUrl`] to the keyring. + /// Store credentials for the given [`DisplaySafeUrl`] in the keyring. /// /// Only the native keyring provider is supported at this time. - #[instrument(skip_all, fields(url = % url.to_string(), username))] + #[instrument(skip_all, fields(url = %url, username))] pub async fn store( &self, url: &DisplaySafeUrl, credentials: &Credentials, - ) -> Result { - let Some(username) = credentials.username() else { - trace!("Unable to store credentials in keyring for `{url}` due to missing username"); - return Ok(false); - }; - let Some(password) = credentials.password() else { - trace!("Unable to store credentials in keyring for `{url}` due to missing password"); - return Ok(false); - }; - - // Ensure we strip credentials from the URL before storing - let url = url.without_credentials(); - - // If there's no path, we'll perform a host-level login - let target = if let Some(host) = url.host_str().filter(|_| !url.path().is_empty()) { - let mut target = String::new(); - if url.scheme() != "https" { - target.push_str(url.scheme()); - target.push_str("://"); - } - target.push_str(host); - if let Some(port) = url.port() { - target.push(':'); - target.push_str(&port.to_string()); - } - target - } else { - url.to_string() - }; + ) -> Result<(), Error> { + if credentials.username().is_none() { + return Err(Error::MissingUsername); + } + if credentials.password().is_none() { + return Err(Error::MissingPassword); + } + + let url = url.without_credentials().into_owned(); + let service = Service::try_from(url).map_err(Error::InvalidService)?; match &self.backend { - KeyringProviderBackend::Native => { - self.store_native(&target, username, password).await?; - Ok(true) - } + KeyringProviderBackend::Native => native::store(&service, credentials).await, KeyringProviderBackend::Subprocess => Err(Error::StoreUnsupported(self.backend.name())), #[cfg(test)] KeyringProviderBackend::Dummy(_) => Err(Error::StoreUnsupported(self.backend.name())), } } - /// Store credentials to the system keyring. - #[instrument(skip_all, fields(service = ?service, username = ?username))] - async fn store_native( - &self, - service: &str, - username: &str, - password: &str, - ) -> Result<(), Error> { - let prefixed_service = format!("{UV_SERVICE_PREFIX}{service}"); - let entry = uv_keyring::Entry::new(&prefixed_service, username)?; - entry.set_password(password).await?; - Ok(()) - } - - /// Remove credentials for the given [`DisplaySafeUrl`] and username from the keyring. + /// Remove credentials for the given [`DisplaySafeUrl`] from the keyring. /// /// Only the native keyring provider is supported at this time. - #[instrument(skip_all, fields(url = % url.to_string(), username))] + #[instrument(skip_all, fields(url = %url, username))] pub async fn remove(&self, url: &DisplaySafeUrl, username: &str) -> Result<(), Error> { - // Ensure we strip credentials from the URL before storing - let url = url.without_credentials(); - - // If there's no path, we'll perform a host-level login - let target = if let Some(host) = url.host_str().filter(|_| !url.path().is_empty()) { - let mut target = String::new(); - if url.scheme() != "https" { - target.push_str(url.scheme()); - target.push_str("://"); - } - target.push_str(host); - if let Some(port) = url.port() { - target.push(':'); - target.push_str(&port.to_string()); - } - target - } else { - url.to_string() - }; + let url = url.without_credentials().into_owned(); + let service = Service::try_from(url).map_err(Error::InvalidService)?; match &self.backend { - KeyringProviderBackend::Native => { - self.remove_native(&target, username).await?; - Ok(()) - } + KeyringProviderBackend::Native => native::remove(&service, username).await, KeyringProviderBackend::Subprocess => { Err(Error::RemoveUnsupported(self.backend.name())) } @@ -173,28 +149,15 @@ impl KeyringProvider { } } - /// Remove credentials from the system keyring for the given `service_name`/`username` - /// pair. - #[instrument(skip(self))] - async fn remove_native( - &self, - service_name: &str, - username: &str, - ) -> Result<(), uv_keyring::Error> { - let prefixed_service = format!("{UV_SERVICE_PREFIX}{service_name}"); - let entry = uv_keyring::Entry::new(&prefixed_service, username)?; - entry.delete_credential().await?; - trace!("Removed credentials for {username}@{service_name} from system keyring"); - Ok(()) - } - - /// Fetch credentials for the given [`Url`] from the keyring. + /// Fetch credentials for the given URL from the keyring. /// - /// Returns [`None`] if no password was found for the username or if any errors - /// are encountered in the keyring backend. - #[instrument(skip_all, fields(url = % url.to_string(), username))] - pub async fn fetch(&self, url: &DisplaySafeUrl, username: Option<&str>) -> Option { - // Validate the request + /// Returns [`Ok(None)`] if no password was found for the username. + #[instrument(skip_all, fields(url = %url, username))] + pub async fn fetch( + &self, + url: &DisplaySafeUrl, + username: Option<&str>, + ) -> Result, Error> { debug_assert!( url.host_str().is_some(), "Should only use keyring for URLs with host" @@ -208,58 +171,63 @@ impl KeyringProvider { "Should only use keyring with a non-empty username" ); - // Check the full URL first - // - trace!("Checking keyring for URL `{url}`"); - let mut credentials = match self.backend { - KeyringProviderBackend::Native => self.fetch_native(url.as_str(), username).await, + match &self.backend { + KeyringProviderBackend::Native => Ok(native::fetch(url, username) + .await? + .map(|fetched| fetched.credentials)), KeyringProviderBackend::Subprocess => { - self.fetch_subprocess(url.as_str(), username).await + let credentials = self.fetch_with_fallback(url, username).await; + Ok(credentials + .map(|(username, password)| Credentials::basic(Some(username), Some(password)))) } #[cfg(test)] - KeyringProviderBackend::Dummy(ref store) => { - Self::fetch_dummy(store, url.as_str(), username) + KeyringProviderBackend::Dummy(_) => { + let credentials = self.fetch_with_fallback(url, username).await; + Ok(credentials + .map(|(username, password)| Credentials::basic(Some(username), Some(password)))) } - }; - // And fallback to a check for the host - if credentials.is_none() { - let host = if let Some(port) = url.port() { - format!("{}:{}", url.host_str()?, port) - } else { - url.host_str()?.to_string() - }; - trace!("Checking keyring for host {host}"); - credentials = match self.backend { - KeyringProviderBackend::Native => self.fetch_native(&host, username).await, - KeyringProviderBackend::Subprocess => self.fetch_subprocess(&host, username).await, - #[cfg(test)] - KeyringProviderBackend::Dummy(ref store) => { - Self::fetch_dummy(store, &host, username) - } - }; + } + } - // For non-HTTPS URLs, `store` includes the scheme in the service name - // (e.g., `http://host:port`) to avoid leaking credentials across schemes. - // Try `scheme://host:port` as a fallback to match those entries. - if credentials.is_none() && url.scheme() != "https" { - let scheme_host = format!("{}://{host}", url.scheme()); - trace!("Checking keyring for scheme+host {scheme_host}"); - credentials = match self.backend { - KeyringProviderBackend::Native => { - self.fetch_native(&scheme_host, username).await - } - KeyringProviderBackend::Subprocess => { - self.fetch_subprocess(&scheme_host, username).await - } - #[cfg(test)] - KeyringProviderBackend::Dummy(ref store) => { - Self::fetch_dummy(store, &scheme_host, username) - } - }; - } + /// Fetch subprocess credentials using the legacy URL, host, and scheme-host lookup order. + async fn fetch_with_fallback( + &self, + url: &DisplaySafeUrl, + username: Option<&str>, + ) -> Option<(String, String)> { + trace!("Checking keyring for URL `{url}`"); + let mut credentials = self.fetch_service(url.as_str(), username).await; + if credentials.is_some() { + return credentials; } - credentials.map(|(username, password)| Credentials::basic(Some(username), Some(password))) + let host = legacy_host(url)?; + trace!("Checking keyring for host `{host}`"); + credentials = self.fetch_service(&host, username).await; + if credentials.is_none() && url.scheme() != "https" { + let scheme_host = format!("{}://{host}", url.scheme()); + trace!("Checking keyring for scheme+host `{scheme_host}`"); + credentials = self.fetch_service(&scheme_host, username).await; + } + credentials + } + + /// Look up one legacy service; all backends share the same fallback traversal. + async fn fetch_service( + &self, + service_name: &str, + username: Option<&str>, + ) -> Option<(String, String)> { + match &self.backend { + KeyringProviderBackend::Native => None, + KeyringProviderBackend::Subprocess => { + self.fetch_subprocess(service_name, username).await + } + #[cfg(test)] + KeyringProviderBackend::Dummy(store) => { + Self::fetch_dummy(store, service_name, username) + } + } } #[instrument(skip(self))] @@ -268,7 +236,6 @@ impl KeyringProvider { service_name: &str, username: Option<&str>, ) -> Option<(String, String)> { - // https://github.com/pypa/pip/blob/24.0/src/pip/_internal/network/auth.py#L136-L141 let mut command = Command::new("keyring"); command.arg("get").arg(service_name); @@ -281,9 +248,6 @@ impl KeyringProvider { let child = command .stdin(Stdio::null()) .stdout(Stdio::piped()) - // If we're using `--mode creds`, we need to capture the output in order to avoid - // showing users an "unrecognized arguments: --mode" error; otherwise, we stream stderr - // so the user has visibility into keyring's behavior if it's doing something slow .stderr(if username.is_some() { Stdio::inherit() } else { @@ -300,14 +264,7 @@ impl KeyringProvider { .ok()?; if output.status.success() { - // If we captured stderr, display it in case it's helpful to the user - // TODO(zanieb): This was done when we added `--mode creds` support for parity with the - // existing behavior, but it might be a better UX to hide this on success? It also - // might be problematic that we're not streaming it. We could change this given some - // user feedback. std::io::stderr().write_all(&output.stderr).ok(); - - // On success, parse the newline terminated credentials let output = String::from_utf8(output.stdout) .inspect_err(|err| warn!("Failed to parse response from `keyring` command: {err}")) .ok()?; @@ -320,7 +277,6 @@ impl KeyringProvider { .unwrap_or(&output); (username, password) } else { - // We're expecting a username and password let mut lines = output.lines(); let username = lines.next()?; let Some(password) = lines.next() else { @@ -333,62 +289,25 @@ impl KeyringProvider { }; if password.is_empty() { - // We allow this for backwards compatibility, but it might be better to return - // `None` instead if there's confusion from users — we haven't seen this in practice - // yet. warn!("Got empty password for `{username}@{service_name}` from `keyring` command"); } - Some((username.to_string(), password.to_string())) } else { - // On failure, no password was available let stderr = std::str::from_utf8(&output.stderr).ok()?; if stderr.contains("unrecognized arguments: --mode") { - // N.B. We do not show the `service_name` here because we'll show the warning twice - // otherwise, once for the URL and once for the realm. warn_user_once!( "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" ); } else if username.is_none() { - // If we captured stderr, display it in case it's helpful to the user std::io::stderr().write_all(&output.stderr).ok(); } None } } - #[instrument(skip(self))] - async fn fetch_native( - &self, - service: &str, - username: Option<&str>, - ) -> Option<(String, String)> { - let prefixed_service = format!("{UV_SERVICE_PREFIX}{service}"); - let username = username?; - let Ok(entry) = uv_keyring::Entry::new(&prefixed_service, username) else { - return None; - }; - match entry.get_password().await { - Ok(password) => return Some((username.to_string(), password)), - Err(uv_keyring::Error::NoEntry) => { - debug!("No entry found in system keyring for {service}"); - } - Err(err) => { - warn_user_once_with_chain!( - anyhow::Error::from(err) - .context(format!( - "Unable to fetch credentials for {service} from system keyring" - )) - .as_ref() - ); - } - } - None - } - #[cfg(test)] fn fetch_dummy( - store: &Vec<(String, &'static str, &'static str)>, + store: &[(String, &'static str, &'static str)], service_name: &str, username: Option<&str>, ) -> Option<(String, String)> { @@ -401,7 +320,7 @@ impl KeyringProvider { }) } - /// Create a new provider with [`KeyringProviderBackend::Dummy`]. + /// Create a provider backed by static test credentials. #[cfg(test)] pub(crate) fn dummy< S: Into, @@ -418,7 +337,7 @@ impl KeyringProvider { } } - /// Create a new provider with no credentials available. + /// Create a test provider with no credentials. #[cfg(test)] fn empty() -> Self { Self { @@ -427,11 +346,27 @@ impl KeyringProvider { } } +/// Load all persisted native credentials for one authentication realm. +pub(crate) async fn load_native_realm(realm: &Realm) -> Result, Error> { + native::load_realm(realm).await +} + +/// Return the host and optional explicit port used by legacy keyring lookups. +fn legacy_host(url: &DisplaySafeUrl) -> Option { + let host = url.host_str()?; + Some(if let Some(port) = url.port() { + format!("{host}:{port}") + } else { + host.to_string() + }) +} + #[cfg(test)] mod tests { - use super::*; use url::Url; + use super::*; + #[tokio::test] #[cfg_attr( debug_assertions, @@ -440,11 +375,11 @@ mod tests { async fn fetch_url_no_host() { let url = Url::parse("file:/etc/bin/").unwrap(); let keyring = KeyringProvider::empty(); - // Panics due to debug assertion; returns `None` in production assert_eq!( keyring .fetch(DisplaySafeUrl::ref_cast(&url), Some("user")) - .await, + .await + .unwrap(), None ); } @@ -457,11 +392,11 @@ mod tests { async fn fetch_url_with_password() { let url = Url::parse("https://user:password@example.com").unwrap(); let keyring = KeyringProvider::empty(); - // Panics due to debug assertion; returns `None` in production assert_eq!( keyring .fetch(DisplaySafeUrl::ref_cast(&url), Some(url.username())) - .await, + .await + .unwrap(), None ); } @@ -474,61 +409,15 @@ mod tests { async fn fetch_url_with_empty_username() { let url = Url::parse("https://example.com").unwrap(); let keyring = KeyringProvider::empty(); - // Panics due to debug assertion; returns `None` in production assert_eq!( keyring .fetch(DisplaySafeUrl::ref_cast(&url), Some(url.username())) - .await, + .await + .unwrap(), None ); } - #[tokio::test] - async fn fetch_url_no_auth() { - let url = Url::parse("https://example.com").unwrap(); - let url = DisplaySafeUrl::ref_cast(&url); - let keyring = KeyringProvider::empty(); - let credentials = keyring.fetch(url, Some("user")); - assert!(credentials.await.is_none()); - } - - #[tokio::test] - async fn fetch_url() { - let url = Url::parse("https://example.com").unwrap(); - let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]); - assert_eq!( - keyring - .fetch(DisplaySafeUrl::ref_cast(&url), Some("user")) - .await, - Some(Credentials::basic( - Some("user".to_string()), - Some("password".to_string()) - )) - ); - assert_eq!( - keyring - .fetch( - DisplaySafeUrl::ref_cast(&url.join("test").unwrap()), - Some("user") - ) - .await, - Some(Credentials::basic( - Some("user".to_string()), - Some("password".to_string()) - )) - ); - } - - #[tokio::test] - async fn fetch_url_no_match() { - let url = Url::parse("https://example.com").unwrap(); - let keyring = KeyringProvider::dummy([("other.com", "user", "password")]); - let credentials = keyring - .fetch(DisplaySafeUrl::ref_cast(&url), Some("user")) - .await; - assert_eq!(credentials, None); - } - #[tokio::test] async fn fetch_url_prefers_url_to_host() { let url = Url::parse("https://example.com/").unwrap(); @@ -542,108 +431,36 @@ mod tests { DisplaySafeUrl::ref_cast(&url.join("foo").unwrap()), Some("user") ) - .await, - Some(Credentials::basic( - Some("user".to_string()), - Some("password".to_string()) - )) + .await + .unwrap() + .and_then(|credentials| credentials.password().map(str::to_string)), + Some("password".to_string()) ); - assert_eq!( - keyring - .fetch(DisplaySafeUrl::ref_cast(&url), Some("user")) - .await, - Some(Credentials::basic( - Some("user".to_string()), - Some("other-password".to_string()) - )) - ); - assert_eq!( - keyring - .fetch( - DisplaySafeUrl::ref_cast(&url.join("bar").unwrap()), - Some("user") - ) - .await, - Some(Credentials::basic( - Some("user".to_string()), - Some("other-password".to_string()) - )) - ); - } - - #[tokio::test] - async fn fetch_url_username() { - let url = Url::parse("https://example.com").unwrap(); - let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]); - let credentials = keyring - .fetch(DisplaySafeUrl::ref_cast(&url), Some("user")) - .await; - assert_eq!( - credentials, - Some(Credentials::basic( - Some("user".to_string()), - Some("password".to_string()) - )) - ); - } - - #[tokio::test] - async fn fetch_url_no_username() { - let url = Url::parse("https://example.com").unwrap(); - let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "user", "password")]); - let credentials = keyring.fetch(DisplaySafeUrl::ref_cast(&url), None).await; - assert_eq!( - credentials, - Some(Credentials::basic( - Some("user".to_string()), - Some("password".to_string()) - )) - ); - } - - #[tokio::test] - async fn fetch_url_username_no_match() { - let url = Url::parse("https://example.com").unwrap(); - let keyring = KeyringProvider::dummy([(url.host_str().unwrap(), "foo", "password")]); - let credentials = keyring - .fetch(DisplaySafeUrl::ref_cast(&url), Some("bar")) - .await; - assert_eq!(credentials, None); - - // Still fails if we have `foo` in the URL itself - let url = Url::parse("https://foo@example.com").unwrap(); - let credentials = keyring - .fetch(DisplaySafeUrl::ref_cast(&url), Some("bar")) - .await; - assert_eq!(credentials, None); } #[tokio::test] async fn fetch_http_scheme_host_fallback() { - // When credentials are stored with scheme included (e.g., `http://host:port`), - // the fetch should find them via the `scheme://host:port` fallback. let url = Url::parse("http://127.0.0.1:8080/basic-auth/simple/anyio/").unwrap(); let keyring = KeyringProvider::dummy([("http://127.0.0.1:8080", "user", "password")]); - let credentials = keyring - .fetch(DisplaySafeUrl::ref_cast(&url), Some("user")) - .await; - assert_eq!( - credentials, - Some(Credentials::basic( - Some("user".to_string()), - Some("password".to_string()) - )) + assert!( + keyring + .fetch(DisplaySafeUrl::ref_cast(&url), Some("user")) + .await + .unwrap() + .is_some() ); } #[tokio::test] - async fn fetch_http_scheme_host_no_cross_scheme() { - // Credentials stored under `http://` should not be returned for `https://` requests. + async fn fetch_http_scheme_host_does_not_cross_schemes() { let url = Url::parse("https://127.0.0.1:8080/basic-auth/simple/anyio/").unwrap(); let keyring = KeyringProvider::dummy([("http://127.0.0.1:8080", "user", "password")]); - let credentials = keyring - .fetch(DisplaySafeUrl::ref_cast(&url), Some("user")) - .await; - assert_eq!(credentials, None); + assert_eq!( + keyring + .fetch(DisplaySafeUrl::ref_cast(&url), Some("user")) + .await + .unwrap(), + None + ); } } diff --git a/crates/uv-auth/src/keyring/native.rs b/crates/uv-auth/src/keyring/native.rs new file mode 100644 index 00000000000..6f547ab7347 --- /dev/null +++ b/crates/uv-auth/src/keyring/native.rs @@ -0,0 +1,653 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + str::FromStr, + sync::{Arc, LazyLock, Mutex, Weak}, +}; + +use serde::{Deserialize, Serialize}; +use tokio::sync::{OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock as AsyncRwLock}; +use tracing::{instrument, trace, warn}; +use uv_cache_key::cache_digest; +use uv_fs::{LockedFile, LockedFileMode}; +use uv_redacted::DisplaySafeUrl; +#[cfg(windows)] +use uv_state::{StateBucket, StateStore}; + +use super::Error; +use crate::{Credentials, Realm, Service, Username, matching, persistent::PersistentCredential}; + +#[cfg(not(target_os = "windows"))] +use collection as platform; +#[cfg(target_os = "windows")] +use windows as platform; + +#[cfg(not(target_os = "windows"))] +mod collection; +#[cfg(target_os = "windows")] +mod windows; + +/// Keyring service prefix for uv credentials. +pub(super) const SERVICE_PREFIX: &str = "uv:"; + +/// Process-local locks for native credential operations. +static NATIVE_LOCKS: LazyLock>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// A read lock for persisted credentials in one realm. +pub(super) struct RealmReadGuard { + realm: Realm, + _process: OwnedRwLockReadGuard<()>, + _file: LockedFile, +} + +/// A write lock for persisted credentials in one realm. +pub(super) struct RealmWriteGuard { + realm: Realm, + _process: OwnedRwLockWriteGuard<()>, + _file: LockedFile, +} + +/// A read lock for one exact legacy keyring service name. +pub(super) struct LegacyReadGuard { + service_name: String, + _process: OwnedRwLockReadGuard<()>, + _file: LockedFile, +} + +/// A write lock for one exact legacy keyring service name. +pub(super) struct LegacyWriteGuard { + service_name: String, + _process: OwnedRwLockWriteGuard<()>, + _file: LockedFile, +} + +/// A borrowed realm lock accepted by read-only backend operations. +#[derive(Clone, Copy)] +pub(super) enum RealmGuardRef<'a> { + Read(&'a RealmReadGuard), + Write(&'a RealmWriteGuard), +} + +impl RealmGuardRef<'_> { + /// Return the realm protected by this guard. + fn realm(&self) -> &Realm { + match self { + Self::Read(guard) => &guard.realm, + Self::Write(guard) => &guard.realm, + } + } +} + +impl RealmWriteGuard { + /// Return the realm protected by this guard. + fn realm(&self) -> &Realm { + &self.realm + } +} + +/// Credentials persisted for one authentication realm. +#[derive(Debug, Default, Deserialize, Serialize)] +#[serde(transparent)] +pub(super) struct PersistedCredentials(Vec); + +impl PersistedCredentials { + /// Iterate over the persisted credentials. + fn iter(&self) -> impl Iterator { + self.0.iter() + } + + /// Select the most specific credential matching a URL and optional username. + fn select( + &self, + url: &DisplaySafeUrl, + username: Option<&str>, + ) -> Result, Error> { + matching::select_credential( + self.0.iter().map(|credential| { + ( + &credential.service, + credential.credentials.username(), + &credential.credentials, + ) + }), + url, + username, + ) + .map_err(|_| Error::AmbiguousUsername(url.clone())) + } +} + +/// A borrowed legacy lock accepted by read-only backend operations. +#[derive(Clone, Copy)] +pub(super) enum LegacyGuardRef<'a> { + Read(&'a LegacyReadGuard), + Write(&'a LegacyWriteGuard), +} + +impl LegacyGuardRef<'_> { + /// Return the exact legacy service name protected by this guard. + fn service_name(&self) -> &str { + match self { + Self::Read(guard) => &guard.service_name, + Self::Write(guard) => &guard.service_name, + } + } +} + +/// Store credentials for an exact service in the platform keyring. +#[instrument(skip(credentials))] +pub(super) async fn store(service: &Service, credentials: &Credentials) -> Result<(), Error> { + let realm = Realm::from(service.url()); + let guard = acquire_realm_write(&realm).await?; + platform::store_persisted_credential( + &guard, + &PersistentCredential { + service: service.clone(), + credentials: credentials.clone(), + }, + ) + .await +} + +/// Load the complete persisted credential snapshot for one authentication realm. +#[instrument] +pub(super) async fn load_realm(realm: &Realm) -> Result, Error> { + let guard = acquire_realm_read(realm).await?; + platform::load_persisted_credentials(RealmGuardRef::Read(&guard)) + .await + .map(|credentials| credentials.0) +} + +/// Remove credentials for an exact service and username from the platform keyring. +#[instrument] +pub(super) async fn remove(service: &Service, username: &str) -> Result<(), Error> { + let realm = Realm::from(service.url()); + let realm_guard = acquire_realm_write(&realm).await?; + + let mut removed_legacy = false; + for service_name in legacy_removal_service_names(service.url()) { + if platform::is_persisted_entry(&realm, &service_name, username) { + continue; + } + let legacy_guard = acquire_legacy_write(&service_name).await?; + removed_legacy |= system_remove_legacy(&legacy_guard, username).await?; + } + + let username = Username::from(Some(username.to_string())); + let removed_persisted = + platform::remove_persisted_credential(&realm_guard, service, &username).await?; + + if removed_persisted || removed_legacy { + Ok(()) + } else { + Err(Error::Keyring(uv_keyring::Error::NoEntry)) + } +} + +/// A native credential and its persisted service scope, when available. +pub(crate) struct NativeCredentials { + pub(crate) credentials: Credentials, + pub(crate) snapshot: Option>, +} + +/// Fetch the best matching credentials, migrating a legacy entry when safe. +#[instrument] +pub(crate) async fn fetch( + url: &DisplaySafeUrl, + username: Option<&str>, +) -> Result, Error> { + let url = url.without_credentials(); + let url = DisplaySafeUrl::ref_cast(url.as_ref()); + let realm = Realm::from(url); + let legacy_match = { + let realm_guard = acquire_realm_read(&realm).await?; + let credentials_snapshot = + platform::load_persisted_credentials(RealmGuardRef::Read(&realm_guard)).await?; + + if let Some(credentials) = credentials_snapshot.select(url, username)? { + return Ok(Some(NativeCredentials { + credentials: credentials.clone(), + snapshot: Some(credentials_snapshot.0), + })); + } + + let mut legacy_match = None; + for service_name in legacy_service_names(url) { + let Some(username) = username else { + break; + }; + if platform::is_persisted_entry(&realm, &service_name, username) { + continue; + } + let legacy_guard = acquire_legacy_read(&service_name).await?; + if let Some(password) = + system_fetch_legacy(LegacyGuardRef::Read(&legacy_guard), username).await? + { + legacy_match = Some(( + service_name.clone(), + Credentials::basic(Some(username.to_string()), Some(password)), + should_migrate_legacy_service(url, &service_name), + )); + break; + } + } + legacy_match + }; + + let Some((service_name, credentials, should_migrate)) = legacy_match else { + return Ok(None); + }; + + let snapshot = if should_migrate { + match migrate_legacy_credential(&realm, &service_name, &credentials).await { + Ok(snapshot) => snapshot, + Err(err) => { + warn!("Failed to migrate legacy credentials in realm {realm}: {err}"); + None + } + } + } else { + None + }; + + Ok(Some(NativeCredentials { + credentials, + snapshot, + })) +} + +/// Migrate an unchanged legacy credential into persisted realm storage. +#[instrument(skip(service_name, credentials), fields(realm = %realm))] +async fn migrate_legacy_credential( + realm: &Realm, + service_name: &str, + credentials: &Credentials, +) -> Result>, Error> { + let Some(username) = credentials.username() else { + return Ok(None); + }; + + let service = if service_name.contains("://") { + Service::from_str(service_name) + } else { + Service::from_str(&realm.to_string()) + }; + let Ok(service) = service else { + warn!("Failed to parse a legacy credential service during migration"); + return Ok(None); + }; + if Realm::from(service.url()) != *realm { + trace!("Skipping migration for a legacy credential outside realm `{realm}`"); + return Ok(None); + } + + let realm_guard = acquire_realm_write(realm).await?; + let legacy_guard = acquire_legacy_write(service_name).await?; + let Some(current_password) = + system_fetch_legacy(LegacyGuardRef::Write(&legacy_guard), username).await? + else { + return Ok(None); + }; + if credentials.password() != Some(current_password.as_str()) { + return Ok(None); + } + + let credential = PersistentCredential { + service, + credentials: Credentials::basic(Some(username.to_string()), Some(current_password)), + }; + let mut persisted_credentials = + platform::load_persisted_credentials(RealmGuardRef::Write(&realm_guard)).await?; + if persisted_credentials.iter().any(|persisted| { + persisted.service == credential.service + && persisted.credentials.to_username() == credential.credentials.to_username() + }) { + system_remove_legacy(&legacy_guard, username).await?; + return Ok(Some(persisted_credentials.0)); + } + platform::store_persisted_credential(&realm_guard, &credential).await?; + system_remove_legacy(&legacy_guard, username).await?; + persisted_credentials.0.push(credential); + Ok(Some(persisted_credentials.0)) +} + +/// Return legacy service names in lookup order. +fn legacy_service_names(url: &DisplaySafeUrl) -> Vec { + let mut service_names = vec![url.as_str().to_string()]; + if let Some(host) = legacy_host_service_name(url) { + if url.scheme() != "https" { + service_names.push(format!("{}://{host}", url.scheme())); + } + service_names.push(host); + } + service_names.dedup(); + service_names +} + +/// Return legacy service names that an exact logout may safely remove. +fn legacy_removal_service_names(url: &DisplaySafeUrl) -> Vec { + let mut service_names = vec![url.as_str().to_string()]; + if let Some(host) = legacy_host_service_name(url) { + if url.scheme() == "https" { + service_names.push(host); + } else { + service_names.push(format!("{}://{host}", url.scheme())); + } + } + service_names.dedup(); + service_names +} + +/// Return the host and optional explicit port used by legacy entries. +fn legacy_host_service_name(url: &DisplaySafeUrl) -> Option { + let host = url.host_str()?; + Some(if let Some(port) = url.port() { + format!("{host}:{port}") + } else { + host.to_string() + }) +} + +/// Return whether a legacy entry can be permanently scoped to the request realm. +fn should_migrate_legacy_service(url: &DisplaySafeUrl, service_name: &str) -> bool { + service_name != url.as_str() + && !(url.scheme() != "https" + && legacy_host_service_name(url).as_deref() == Some(service_name)) +} + +/// Ensure that a service belongs to the realm protected by a lock guard. +pub(super) fn ensure_service_realm(realm: &Realm, service: &Service) -> Result<(), Error> { + if Realm::from(service.url()) == *realm { + Ok(()) + } else { + Err(Error::MismatchedRealm) + } +} + +/// Return a user-scoped native lock directory independent of configurable state directories. +fn native_lock_directory() -> Result { + #[cfg(windows)] + { + Ok(StateStore::from_settings(None) + .map_err(Error::NativeLockDirectory)? + .bucket(StateBucket::Credentials) + .join("native")) + } + #[cfg(not(windows))] + { + let home = etcetera::home_dir() + .map_err(|err| Error::NativeLockDirectory(std::io::Error::other(err)))?; + Ok(home.join(".local/share/uv/credentials/native")) + } +} + +/// Return the lock path for a stable credential operation key. +fn native_lock_path(directory: &Path, key: &str) -> PathBuf { + directory.join(format!("{}.lock", cache_digest(&key))) +} + +/// Return the process-local lock for a stable credential operation key. +fn process_lock(key: &str) -> Arc> { + let key = cache_digest(&key); + let mut locks = match NATIVE_LOCKS.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + locks.retain(|_, lock| lock.strong_count() > 0); + if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) { + return lock; + } + + let lock = Arc::new(AsyncRwLock::new(())); + locks.insert(key, Arc::downgrade(&lock)); + lock +} + +/// Create and return the directory containing native credential lock files. +fn create_native_lock_directory() -> Result { + let directory = native_lock_directory()?; + fs_err::create_dir_all(&directory).map_err(Error::NativeLockDirectory)?; + Ok(directory) +} + +/// Acquire a shared lock for one realm. +async fn acquire_realm_read(realm: &Realm) -> Result { + let key = format!("realm:{realm}"); + let process = process_lock(&key).read_owned().await; + let directory = create_native_lock_directory()?; + let file = LockedFile::acquire( + native_lock_path(&directory, &key), + LockedFileMode::Shared, + format!("native credential store for {realm}"), + ) + .await + .map_err(Error::NativeLock)?; + Ok(RealmReadGuard { + realm: realm.clone(), + _process: process, + _file: file, + }) +} + +/// Acquire an exclusive lock for one realm. +async fn acquire_realm_write(realm: &Realm) -> Result, Error> { + let key = format!("realm:{realm}"); + let process = process_lock(&key).write_owned().await; + let directory = create_native_lock_directory()?; + let file = LockedFile::acquire( + native_lock_path(&directory, &key), + LockedFileMode::Exclusive, + format!("native credential store for {realm}"), + ) + .await + .map_err(Error::NativeLock)?; + Ok(Arc::new(RealmWriteGuard { + realm: realm.clone(), + _process: process, + _file: file, + })) +} + +/// Acquire a shared lock for one exact legacy service name. +async fn acquire_legacy_read(service_name: &str) -> Result { + let key = legacy_lock_key(service_name); + let process = process_lock(&key).read_owned().await; + let directory = create_native_lock_directory()?; + let file = LockedFile::acquire( + native_lock_path(&directory, &key), + LockedFileMode::Shared, + "legacy native credential", + ) + .await + .map_err(Error::NativeLock)?; + Ok(LegacyReadGuard { + service_name: service_name.to_string(), + _process: process, + _file: file, + }) +} + +/// Acquire an exclusive lock for one exact legacy service name. +async fn acquire_legacy_write(service_name: &str) -> Result, Error> { + let key = legacy_lock_key(service_name); + let process = process_lock(&key).write_owned().await; + let directory = create_native_lock_directory()?; + let file = LockedFile::acquire( + native_lock_path(&directory, &key), + LockedFileMode::Exclusive, + "legacy native credential", + ) + .await + .map_err(Error::NativeLock)?; + Ok(Arc::new(LegacyWriteGuard { + service_name: service_name.to_string(), + _process: process, + _file: file, + })) +} + +/// Return a bounded lock identity for a legacy service. +/// +/// URL-based legacy entries are already scoped to a realm. Bare-host entries use a host lock +/// shared by HTTP and HTTPS so cross-scheme fallbacks cannot race one another. +fn legacy_lock_key(service_name: &str) -> String { + if let Ok(url) = DisplaySafeUrl::parse(service_name) { + format!("legacy-realm:{}", Realm::from(&url)) + } else { + format!("legacy-host:{service_name}") + } +} + +/// Legacy identities that exceed platform limits could never have been stored. +fn legacy_entry(service_name: &str, username: &str) -> Result, Error> { + match uv_keyring::Entry::new(&format!("{SERVICE_PREFIX}{service_name}"), username) { + Ok(entry) => Ok(Some(entry)), + Err(uv_keyring::Error::TooLong(..)) => Ok(None), + Err(err) => Err(Error::Keyring(err)), + } +} + +/// Fetch a legacy password from the system keyring. +async fn system_fetch_legacy( + guard: LegacyGuardRef<'_>, + username: &str, +) -> Result, Error> { + let Some(entry) = legacy_entry(guard.service_name(), username)? else { + return Ok(None); + }; + match entry.get_password().await { + Ok(password) => Ok(Some(password)), + Err(uv_keyring::Error::NoEntry) => Ok(None), + Err(err) => Err(Error::Keyring(err)), + } +} + +/// Remove a legacy password from the system keyring. +async fn system_remove_legacy( + guard: &Arc, + username: &str, +) -> Result { + let Some(entry) = legacy_entry(&guard.service_name, username)? else { + return Ok(false); + }; + match uv_keyring::with_operation_guard(Arc::clone(guard), entry.delete_credential()).await { + Ok(()) => Ok(true), + Err(uv_keyring::Error::NoEntry) => Ok(false), + Err(err) => Err(Error::Keyring(err)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_lock_directory_child() -> Result<(), Box> { + if let Some(output) = std::env::var_os("UV_TEST_NATIVE_LOCK_DIRECTORY_OUTPUT") { + fs_err::write(output, serde_json::to_vec(&native_lock_directory()?)?)?; + } + Ok(()) + } + + #[test] + fn native_lock_directory_is_shared_across_xdg_overrides() + -> Result<(), Box> { + let temporary = tempfile::tempdir()?; + let first = temporary.path().join("first.json"); + let second = temporary.path().join("second.json"); + let program = std::env::current_exe()?; + let first_status = std::process::Command::new(&program) + .args([ + "--exact", + "keyring::native::tests::native_lock_directory_child", + ]) + .env("XDG_DATA_HOME", temporary.path().join("first-state")) + .env( + "UV_CREDENTIALS_DIR", + temporary.path().join("first-credentials"), + ) + .env("UV_TEST_NATIVE_LOCK_DIRECTORY_OUTPUT", &first) + .status()?; + let second_status = std::process::Command::new(&program) + .args([ + "--exact", + "keyring::native::tests::native_lock_directory_child", + ]) + .env("XDG_DATA_HOME", temporary.path().join("second-state")) + .env( + "UV_CREDENTIALS_DIR", + temporary.path().join("second-credentials"), + ) + .env("UV_TEST_NATIVE_LOCK_DIRECTORY_OUTPUT", &second) + .status()?; + assert!(first_status.success()); + assert!(second_status.success()); + let first: PathBuf = serde_json::from_slice(&fs_err::read(first)?)?; + let second: PathBuf = serde_json::from_slice(&fs_err::read(second)?)?; + assert_eq!(first, second); + assert!(!first.starts_with(temporary.path())); + Ok(()) + } + + #[test] + fn lock_path_is_pure() { + let directory = Path::new("/credentials/native"); + assert_eq!( + native_lock_path(directory, "realm:https://example.com"), + directory.join(format!( + "{}.lock", + cache_digest(&"realm:https://example.com") + )) + ); + } + + #[test] + fn legacy_service_names_preserve_lookup_order() { + let url = DisplaySafeUrl::parse("http://localhost:8080/path").unwrap(); + assert_eq!( + legacy_service_names(&url), + [ + "http://localhost:8080/path", + "http://localhost:8080", + "localhost:8080", + ] + ); + } + + #[test] + fn legacy_migration_preserves_service_scope() { + let https_url = + DisplaySafeUrl::parse("https://example.com/path").expect("HTTPS URL should be valid"); + assert!(!should_migrate_legacy_service( + &https_url, + https_url.as_str() + )); + assert!(should_migrate_legacy_service(&https_url, "example.com")); + + let http_url = + DisplaySafeUrl::parse("http://localhost:8080/path").expect("HTTP URL should be valid"); + assert!(should_migrate_legacy_service( + &http_url, + "http://localhost:8080" + )); + assert!(!should_migrate_legacy_service(&http_url, "localhost:8080")); + } + + #[test] + fn legacy_removal_does_not_cross_schemes() { + let https_url = + DisplaySafeUrl::parse("https://example.com/path").expect("HTTPS URL should be valid"); + assert_eq!( + legacy_removal_service_names(&https_url), + ["https://example.com/path", "example.com"] + ); + + let http_url = + DisplaySafeUrl::parse("http://localhost:8080/path").expect("HTTP URL should be valid"); + assert_eq!( + legacy_removal_service_names(&http_url), + ["http://localhost:8080/path", "http://localhost:8080"] + ); + } +} diff --git a/crates/uv-auth/src/keyring/native/collection.rs b/crates/uv-auth/src/keyring/native/collection.rs new file mode 100644 index 00000000000..38e00744d3d --- /dev/null +++ b/crates/uv-auth/src/keyring/native/collection.rs @@ -0,0 +1,220 @@ +use std::sync::Arc; + +use tracing::trace; +use zeroize::Zeroizing; + +use super::{ + Error, PersistedCredentials, RealmGuardRef, RealmWriteGuard, SERVICE_PREFIX, + ensure_service_realm, +}; +use crate::{Realm, Service, Username, persistent::PersistentCredential}; + +/// Username for the single JSON entry that stores every credential in a realm. +const PERSISTED_CREDENTIALS_USERNAME: &str = "uv"; +/// Namespace that keeps JSON collections separate from legacy `uv:` entries. +const PERSISTED_CREDENTIALS_NAMESPACE: &str = "credentials:"; + +impl PersistedCredentials { + /// Return whether the collection is empty. + fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Insert or replace one exact service and username. + fn upsert(&mut self, credential: &PersistentCredential) { + let username = credential.credentials.to_username(); + self.0.retain(|stored| { + stored.service != credential.service || stored.credentials.to_username() != username + }); + self.0.push(credential.clone()); + } + + /// Remove one exact service and username. + fn remove(&mut self, service: &Service, username: &Username) -> bool { + let initial = self.0.len(); + self.0.retain(|stored| { + stored.service != *service || stored.credentials.to_username() != *username + }); + self.0.len() != initial + } +} + +/// Return whether a legacy key identifies the persisted realm entry. +pub(super) fn is_persisted_entry(realm: &Realm, service_name: &str, username: &str) -> bool { + username == PERSISTED_CREDENTIALS_USERNAME + && service_name + .strip_prefix(PERSISTED_CREDENTIALS_NAMESPACE) + .is_some_and(|service_name| service_name == realm.to_string()) +} + +/// Return the keyring entry containing all persisted credentials for a realm. +fn realm_entry(guard: RealmGuardRef<'_>) -> Result { + uv_keyring::Entry::new( + &format!( + "{SERVICE_PREFIX}{PERSISTED_CREDENTIALS_NAMESPACE}{}", + guard.realm() + ), + PERSISTED_CREDENTIALS_USERNAME, + ) + .map_err(Error::Keyring) +} + +/// Decode the JSON entry containing persisted credentials. +fn decode_persisted_credentials(value: &str) -> Result { + serde_json::from_str(value).map_err(Error::CorruptStoredCredentials) +} + +/// Load the persisted credentials in the locked realm. +pub(super) async fn load_persisted_credentials( + guard: RealmGuardRef<'_>, +) -> Result { + let entry = realm_entry(guard)?; + match entry.get_password().await { + Ok(value) => decode_persisted_credentials(&Zeroizing::new(value)), + Err(uv_keyring::Error::NoEntry) => Ok(PersistedCredentials::default()), + Err(err) => Err(Error::Keyring(err)), + } +} + +/// Store one credential while holding its realm write lock. +pub(super) async fn store_persisted_credential( + guard: &Arc, + credential: &PersistentCredential, +) -> Result<(), Error> { + ensure_service_realm(guard.realm(), &credential.service)?; + let entry = realm_entry(RealmGuardRef::Write(guard))?; + let mut credentials = match entry.get_password().await { + Ok(value) => decode_persisted_credentials(&Zeroizing::new(value))?, + Err(uv_keyring::Error::NoEntry) => PersistedCredentials::default(), + Err(err) => return Err(Error::Keyring(err)), + }; + credentials.upsert(credential); + let json = Zeroizing::new( + serde_json::to_string(&credentials).map_err(Error::SerializeStoredCredentials)?, + ); + uv_keyring::with_operation_guard(Arc::clone(guard), entry.set_password(&json)).await?; + trace!("Stored native credentials for realm {}", guard.realm); + Ok(()) +} + +/// Remove one persisted credential while holding its realm write lock. +pub(super) async fn remove_persisted_credential( + guard: &Arc, + service: &Service, + username: &Username, +) -> Result { + ensure_service_realm(guard.realm(), service)?; + let entry = realm_entry(RealmGuardRef::Write(guard))?; + let json = match entry.get_password().await { + Ok(json) => json, + Err(uv_keyring::Error::NoEntry) => return Ok(false), + Err(err) => return Err(Error::Keyring(err)), + }; + let mut credentials = decode_persisted_credentials(&Zeroizing::new(json))?; + if !credentials.remove(service, username) { + return Ok(false); + } + if credentials.is_empty() { + uv_keyring::with_operation_guard(Arc::clone(guard), entry.delete_credential()).await?; + } else { + let json = Zeroizing::new( + serde_json::to_string(&credentials).map_err(Error::SerializeStoredCredentials)?, + ); + uv_keyring::with_operation_guard(Arc::clone(guard), entry.set_password(&json)).await?; + } + Ok(true) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::{ + PERSISTED_CREDENTIALS_USERNAME, PersistedCredentials, decode_persisted_credentials, + is_persisted_entry, + }; + use crate::{Credentials, Realm, Service, Username, persistent::PersistentCredential}; + + fn credential(service: &str, username: &str, password: &str) -> PersistentCredential { + PersistentCredential { + service: Service::from_str(service).expect("service URL should be valid"), + credentials: Credentials::basic(Some(username.to_string()), Some(password.to_string())), + } + } + + #[test] + fn corrupt_persisted_credentials_are_rejected() { + assert!(decode_persisted_credentials("not JSON").is_err()); + assert!(decode_persisted_credentials("{}").is_err()); + } + + #[test] + fn persisted_realm_entries_do_not_overlap_legacy_service_names() { + let url = Service::from_str("http://localhost:1234") + .expect("localhost service URL should be valid"); + let realm = Realm::from(url.url()); + assert!(is_persisted_entry( + &realm, + "credentials:http://localhost:1234", + PERSISTED_CREDENTIALS_USERNAME + )); + assert!(!is_persisted_entry( + &realm, + "http://localhost:1234", + PERSISTED_CREDENTIALS_USERNAME + )); + assert!(!is_persisted_entry( + &realm, + "localhost:1234", + PERSISTED_CREDENTIALS_USERNAME + )); + assert!(!is_persisted_entry( + &realm, + "credentials:http://localhost:1234", + "user" + )); + } + + #[test] + fn upsert_replaces_only_the_exact_service_and_username() { + let first = credential("https://example.com/first", "user", "old"); + let sibling_service = credential("https://example.com/second", "user", "sibling"); + let sibling_username = credential("https://example.com/first", "other", "other"); + let replacement = credential("https://example.com/first", "user", "new"); + let mut credentials = PersistedCredentials(vec![ + first, + sibling_service.clone(), + sibling_username.clone(), + ]); + + credentials.upsert(&replacement); + + assert_eq!(credentials.iter().count(), 3); + assert!(credentials.iter().any(|stored| { + stored.service == replacement.service && stored.credentials.password() == Some("new") + })); + assert!(credentials.iter().any(|stored| { + stored.service == sibling_service.service + && stored.credentials.password() == Some("sibling") + })); + assert!(credentials.iter().any(|stored| { + stored.service == sibling_username.service + && stored.credentials.username() == Some("other") + })); + } + + #[test] + fn remove_deletes_only_the_exact_service_and_username() { + let removed = credential("https://example.com/first", "user", "removed"); + let retained = credential("https://example.com/second", "user", "retained"); + let mut credentials = PersistedCredentials(vec![removed.clone(), retained.clone()]); + + assert!(credentials.remove(&removed.service, &Username::from("user".to_string()),)); + let remaining = credentials + .iter() + .next() + .expect("one credential should remain"); + assert_eq!(credentials.iter().count(), 1); + assert_eq!(remaining.service, retained.service); + } +} diff --git a/crates/uv-auth/src/keyring/native/windows.rs b/crates/uv-auth/src/keyring/native/windows.rs new file mode 100644 index 00000000000..60e5419f5b9 --- /dev/null +++ b/crates/uv-auth/src/keyring/native/windows.rs @@ -0,0 +1,200 @@ +use std::sync::Arc; + +use sha2::{Digest, Sha256}; +use tracing::warn; +use zeroize::Zeroizing; + +use super::{ + Error, PersistedCredentials, RealmGuardRef, RealmWriteGuard, SERVICE_PREFIX, + ensure_service_realm, +}; +use crate::{Realm, Service, Username, persistent::PersistentCredential}; + +/// Description attached to uv credentials in Windows Credential Manager. +const CREDENTIAL_COMMENT: &str = "uv native authentication credential"; + +/// Windows targets cannot overlap with legacy keyring entries. +pub(super) fn is_persisted_entry(_realm: &Realm, _service_name: &str, _username: &str) -> bool { + false +} + +/// Return the target prefix used to enumerate credentials in a realm. +fn target_prefix(realm: &Realm) -> String { + format!("{SERVICE_PREFIX}{realm}:") +} + +/// Return a collision-resistant target for one service and username. +fn target(realm: &Realm, service: &Service, username: &Username) -> String { + let service_url = service.url().as_str(); + let username = username.as_deref().unwrap_or_default(); + let identity = format!("{}:{service_url}{username}", service_url.len()); + let digest = hex::encode(Sha256::digest(identity.as_bytes())); + format!("{}{digest}", target_prefix(realm)) +} + +/// Return whether an enumerated target has the expected prefix and digest shape. +fn target_has_valid_shape(target_name: &str, prefix: &str) -> bool { + let Some(stored_prefix) = target_name.get(..prefix.len()) else { + return false; + }; + let Some(digest) = target_name.get(prefix.len()..) else { + return false; + }; + stored_prefix.eq_ignore_ascii_case(prefix) + && digest.len() == 64 + && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +/// Deserialize one credential returned by Windows Credential Manager. +fn decode_credential(secret: &[u8]) -> Result { + serde_json::from_slice(secret) +} + +/// Return whether a credential is stored under its canonical target in a realm. +fn credential_matches_target( + realm: &Realm, + target_name: &str, + credential: &PersistentCredential, +) -> bool { + Realm::from(credential.service.url()) == *realm + && target_name.eq_ignore_ascii_case(&target( + realm, + &credential.service, + &credential.credentials.to_username(), + )) +} + +/// Return a Windows keyring entry for one service and username. +fn entry( + guard: &RealmWriteGuard, + service: &Service, + username: &Username, +) -> Result { + ensure_service_realm(guard.realm(), service)?; + Ok(uv_keyring::Entry::new_with_credential(Box::new( + uv_keyring::windows::WinCredential::with_target( + target(guard.realm(), service, username), + CREDENTIAL_COMMENT.to_string(), + )?, + ))) +} + +/// Load the persisted credentials in the locked realm. +pub(super) async fn load_persisted_credentials( + guard: RealmGuardRef<'_>, +) -> Result { + let realm = guard.realm(); + let prefix = target_prefix(realm); + let entries = uv_keyring::windows::WinCredential::enumerate(&prefix).await?; + let mut credentials = Vec::with_capacity(entries.len()); + for enumerated in entries { + let target_name = enumerated.credential().target_name(); + if !target_has_valid_shape(target_name, &prefix) { + warn!("Ignoring native credential with an invalid target in realm {realm}"); + continue; + } + let credential = match decode_credential(enumerated.secret()) { + Ok(credential) => credential, + Err(err) => { + warn!("Ignoring corrupt native credential in realm {realm}: {err}"); + continue; + } + }; + if !credential_matches_target(realm, target_name, &credential) { + warn!("Ignoring native credential stored under the wrong target"); + continue; + } + credentials.push(credential); + } + Ok(PersistedCredentials(credentials)) +} + +/// Store one credential while holding its realm write lock. +pub(super) async fn store_persisted_credential( + guard: &Arc, + credential: &PersistentCredential, +) -> Result<(), Error> { + let entry = entry( + guard, + &credential.service, + &credential.credentials.to_username(), + )?; + let json = + Zeroizing::new(serde_json::to_vec(credential).map_err(Error::SerializeStoredCredentials)?); + uv_keyring::with_operation_guard(Arc::clone(guard), entry.set_secret(&json)).await?; + Ok(()) +} + +/// Remove one persisted credential while holding its realm write lock. +pub(super) async fn remove_persisted_credential( + guard: &Arc, + service: &Service, + username: &Username, +) -> Result { + match uv_keyring::with_operation_guard( + Arc::clone(guard), + entry(guard, service, username)?.delete_credential(), + ) + .await + { + Ok(()) => Ok(true), + Err(uv_keyring::Error::NoEntry) => Ok(false), + Err(err) => Err(Error::Keyring(err)), + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::{credential_matches_target, decode_credential, target, target_has_valid_shape}; + use crate::{Credentials, Realm, Service, Username, persistent::PersistentCredential}; + + #[test] + fn target_identity_distinguishes_case_collisions_and_signed_urls() { + let service = + Service::from_str("https://example.com/path").expect("service URL should be valid"); + let realm = Realm::from(service.url()); + assert_ne!( + target(&realm, &service, &Username::from("aa".to_string())), + target(&realm, &service, &Username::from("aG".to_string())) + ); + + let first = Service::from_str("https://example.com/path?X-Amz-Signature=one") + .expect("first signed URL should be valid"); + let second = Service::from_str("https://example.com/path?X-Amz-Signature=two") + .expect("second signed URL should be valid"); + let username = Username::from("signed".to_string()); + assert_ne!( + target(&realm, &first, &username), + target(&realm, &second, &username) + ); + } + + #[test] + fn target_validation_rejects_corrupt_and_misplaced_credentials() { + let service = + Service::from_str("https://example.com/path").expect("service URL should be valid"); + let realm = Realm::from(service.url()); + let username = Username::from("user".to_string()); + let target = target(&realm, &service, &username); + let prefix_length = target.len() - 64; + assert!(target_has_valid_shape(&target, &target[..prefix_length])); + assert!(!target_has_valid_shape( + &format!("{}not-a-digest", &target[..prefix_length]), + &target[..prefix_length] + )); + assert!(decode_credential(b"not JSON").is_err()); + + let credential = PersistentCredential { + service, + credentials: Credentials::basic(Some("user".to_string()), Some("password".to_string())), + }; + assert!(credential_matches_target(&realm, &target, &credential)); + assert!(!credential_matches_target( + &realm, + &format!("{}{}", &target[..prefix_length], "0".repeat(64)), + &credential + )); + } +} diff --git a/crates/uv-auth/src/lib.rs b/crates/uv-auth/src/lib.rs index 755b0fcff4e..756aed67c7c 100644 --- a/crates/uv-auth/src/lib.rs +++ b/crates/uv-auth/src/lib.rs @@ -5,14 +5,16 @@ pub use keyring::KeyringProvider; pub use middleware::AuthMiddleware; pub use providers::AzureEndpointProvider; pub use realm::{Realm, RealmRef}; -pub use service::Service; +pub use service::{Service, ServiceParseError}; pub use store::{AuthBackend, TextCredentialStore, TomlCredentialError}; mod cache; mod credentials; mod index; mod keyring; +mod matching; mod middleware; +mod persistent; mod providers; mod realm; mod service; diff --git a/crates/uv-auth/src/matching.rs b/crates/uv-auth/src/matching.rs new file mode 100644 index 00000000000..de868592109 --- /dev/null +++ b/crates/uv-auth/src/matching.rs @@ -0,0 +1,188 @@ +//! Shared credential matching for persistent stores. + +use uv_redacted::DisplaySafeUrl; + +use crate::index::is_path_prefix; +use crate::{RealmRef, Service}; + +/// The best credential match is not unique. +#[derive(Debug, Clone, Copy)] +pub(crate) struct AmbiguousCredential; + +/// Return the path specificity when a stored credential matches the request. +fn match_specificity( + service: &Service, + stored_username: Option<&str>, + request_url: &DisplaySafeUrl, + request_realm: RealmRef<'_>, + request_username: Option<&str>, +) -> Option { + if RealmRef::from(&**service.url()) != request_realm + || !is_path_prefix(service.url().path(), request_url.path()) + || request_username.is_some_and(|username| stored_username != Some(username)) + { + return None; + } + + Some(service.url().path().len()) +} + +/// Select the most specific credential matching a URL and optional username. +pub(crate) fn select_credential<'a, T>( + credentials: impl IntoIterator, &'a T)>, + request_url: &DisplaySafeUrl, + request_username: Option<&str>, +) -> Result, AmbiguousCredential> { + let request_realm = RealmRef::from(&**request_url); + let mut best = None; + let mut best_is_ambiguous = false; + + for (service, stored_username, credential) in credentials { + if let Some(path_specificity) = match_specificity( + service, + stored_username, + request_url, + request_realm, + request_username, + ) { + let exact = service.url() == request_url && stored_username == request_username; + let rank = (exact, path_specificity); + if best.is_none_or(|(best_rank, _)| rank > best_rank) { + best = Some((rank, credential)); + best_is_ambiguous = false; + } else if best.is_some_and(|(best_rank, _)| rank == best_rank) { + best_is_ambiguous = true; + } + } + } + + if best_is_ambiguous { + return Err(AmbiguousCredential); + } + + Ok(best.map(|(_, credential)| credential)) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use uv_redacted::DisplaySafeUrl; + + use super::{match_specificity, select_credential}; + use crate::{RealmRef, Service}; + + #[test] + fn match_specificity_rejects_sibling_paths() { + let service = + Service::from_str("https://example.com/api").expect("service URL should be valid"); + let request_url = DisplaySafeUrl::parse("https://example.com/apiv1") + .expect("request URL should be valid"); + assert_eq!( + match_specificity( + &service, + Some("user"), + &request_url, + RealmRef::from(&*request_url), + Some("user"), + ), + None + ); + } + + #[test] + fn select_credential_prefers_the_most_specific_path() { + let root = + Service::from_str("https://example.com").expect("root service URL should be valid"); + let api = + Service::from_str("https://example.com/api").expect("API service URL should be valid"); + let request = DisplaySafeUrl::parse("https://example.com/api/project") + .expect("request URL should be valid"); + let root_value = "root"; + let api_value = "api"; + + let selected = select_credential( + [ + (&root, Some("user"), &root_value), + (&api, Some("user"), &api_value), + ], + &request, + Some("user"), + ) + .expect("credential match should not be ambiguous"); + + assert_eq!(selected, Some(&api_value)); + } + + #[test] + fn select_credential_rejects_equally_specific_users() { + let service = + Service::from_str("https://example.com/api").expect("service URL should be valid"); + let request = DisplaySafeUrl::parse("https://example.com/api/project") + .expect("request URL should be valid"); + let first = "first"; + let second = "second"; + + assert!( + select_credential( + [ + (&service, Some("first"), &first), + (&service, Some("second"), &second), + ], + &request, + None, + ) + .is_err() + ); + } + + #[test] + fn select_credential_ignores_less_specific_ambiguity() { + let root = + Service::from_str("https://example.com").expect("root service URL should be valid"); + let api = + Service::from_str("https://example.com/api").expect("API service URL should be valid"); + let request = DisplaySafeUrl::parse("https://example.com/api/project") + .expect("request URL should be valid"); + let first_root = "first-root"; + let second_root = "second-root"; + let api_value = "api"; + + let selected = select_credential( + [ + (&root, Some("first"), &first_root), + (&root, Some("second"), &second_root), + (&api, Some("api"), &api_value), + ], + &request, + None, + ) + .expect("less-specific matches should not make the result ambiguous"); + + assert_eq!(selected, Some(&api_value)); + } + + #[test] + fn select_credential_prefers_an_exact_query_match() { + let first = Service::from_str("https://example.com/api?signature=first") + .expect("first service URL should be valid"); + let second = Service::from_str("https://example.com/api?signature=second") + .expect("second service URL should be valid"); + let request = DisplaySafeUrl::parse("https://example.com/api?signature=second") + .expect("request URL should be valid"); + let first_value = "first"; + let second_value = "second"; + + let selected = select_credential( + [ + (&first, Some("user"), &first_value), + (&second, Some("user"), &second_value), + ], + &request, + Some("user"), + ) + .expect("the exact service should resolve the prefix ambiguity"); + + assert_eq!(selected, Some(&second_value)); + } +} diff --git a/crates/uv-auth/src/middleware.rs b/crates/uv-auth/src/middleware.rs index 182fd2379bf..cf17c45b8d9 100644 --- a/crates/uv-auth/src/middleware.rs +++ b/crates/uv-auth/src/middleware.rs @@ -17,7 +17,7 @@ use crate::providers::{ }; use crate::{ CredentialsCache, KeyringProvider, - cache::FetchUrl, + cache::{CredentialsCacheScope, FetchUrl, FetchedCredentials, StoredCredentials}, credentials::{ Authentication, AuthenticationError, Credentials, CredentialsFromUrlError, Username, }, @@ -253,6 +253,9 @@ impl AuthMiddleware { /// Configure the [`Preview`] features to use. #[must_use] pub fn with_preview(mut self, preview: Preview) -> Self { + if preview.is_enabled(PreviewFeature::NativeAuth) { + crate::store::warn_ignored_plaintext_credentials(); + } self.preview = preview; self } @@ -291,6 +294,46 @@ impl AuthMiddleware { fn cache(&self) -> &CredentialsCache { &self.cache } + + /// Return cached stored credentials without widening their original service scope. + fn cached_stored_credentials( + &self, + url: &DisplaySafeUrl, + username: &Username, + ) -> Option> { + if let Ok(credentials) = self.cache().get_stored(url, username) { + credentials + } else { + // Automatic authentication first tries the request without credentials. Selection + // errors are reported only if a challenge or policy requires authentication. + debug!("Multiple stored credentials match URL `{url}`"); + None + } + } + + /// Load all native credentials for a realm once across clients in this invocation. + async fn native_realm_credentials( + &self, + realm: &Realm, + ) -> Result> { + self.cache() + .native_realm(realm, || async { + crate::keyring::load_native_realm(realm) + .await + .map(StoredCredentials::from) + .map_err(Arc::new) + }) + .await + } + + /// Preserve explicit native-store failures when surfacing middleware errors. + fn native_store_error(error: impl Into) -> Error { + Error::Middleware( + error + .into() + .context("Failed to fetch credentials from the native credential store"), + ) + } } #[async_trait::async_trait] @@ -372,6 +415,13 @@ impl Middleware for AuthMiddleware { let credentials = self .cache() .get_url(DisplaySafeUrl::ref_cast(request.url()), &Username::none()); + let credentials = match credentials { + Some(credentials) => Some(credentials), + None => self.cached_stored_credentials( + DisplaySafeUrl::ref_cast(request.url()), + &Username::none(), + ), + }; if let Some(credentials) = credentials.as_ref() { request = credentials.authenticate(request).await?; @@ -473,7 +523,7 @@ impl Middleware for AuthMiddleware { // Then, fetch from external services. // Here, we use the username from the cache if present. - if let Some(credentials) = self + if let Some(fetched_credentials) = self .fetch_credentials( credentials.as_deref(), retry_request_url, @@ -482,11 +532,17 @@ impl Middleware for AuthMiddleware { ) .await? { - retry_request = credentials.authenticate(retry_request).await?; - trace!("Retrying request for `{url}` with {credentials:?}"); + retry_request = fetched_credentials + .credentials + .authenticate(retry_request) + .await?; + trace!( + "Retrying request for `{url}` with {:?}", + fetched_credentials.credentials + ); return self .complete_request( - Some(credentials), + Some(fetched_credentials), retry_request, extensions, next, @@ -521,13 +577,17 @@ impl AuthMiddleware { /// If credentials are present, insert them into the cache on success. async fn complete_request( &self, - credentials: Option>, + credentials: Option, request: Request, extensions: &mut Extensions, next: Next<'_>, auth_policy: AuthPolicy, ) -> reqwest_middleware::Result { - let Some(credentials) = credentials else { + let Some(FetchedCredentials { + credentials, + cache_scope, + }) = credentials + else { // Nothing to insert into the cache if we don't have credentials return next.run(request, extensions).await; }; @@ -545,8 +605,19 @@ impl AuthMiddleware { .is_ok_and(|response| response.error_for_status_ref().is_ok()) { // TODO(zanieb): Consider also updating the system keyring after successful use - trace!("Updating cached credentials for `{url}` to {credentials:?}"); - self.cache().insert(&url, credentials); + match cache_scope { + CredentialsCacheScope::Realm => { + trace!("Updating cached credentials for `{url}` to {credentials:?}"); + self.cache().insert(&url, credentials); + } + CredentialsCacheScope::KeyringFallback => { + self.cache().insert_keyring_realm(&url, &credentials); + } + CredentialsCacheScope::Stored(snapshot) => { + self.cache().insert_stored(&url, snapshot); + } + CredentialsCacheScope::FetchOnly => {} + } } result @@ -569,7 +640,16 @@ impl AuthMiddleware { if credentials.is_authenticated() { trace!("Request for `{url}` already contains complete authentication"); return self - .complete_request(Some(credentials), request, extensions, next, auth_policy) + .complete_request( + Some(FetchedCredentials { + credentials, + cache_scope: CredentialsCacheScope::Realm, + }), + request, + extensions, + next, + auth_policy, + ) .await; } @@ -605,7 +685,7 @@ impl AuthMiddleware { request = credentials.authenticate(request).await?; // Do not insert already-cached credentials None - } else if let Some(credentials) = self + } else if let Some(fetched_credentials) = self .fetch_credentials( Some(&credentials), DisplaySafeUrl::ref_cast(request.url()), @@ -614,8 +694,19 @@ impl AuthMiddleware { ) .await? { - request = credentials.authenticate(request).await?; - Some(credentials) + request = fetched_credentials + .credentials + .authenticate(request) + .await?; + return self + .complete_request( + Some(fetched_credentials), + request, + extensions, + next, + auth_policy, + ) + .await; } else if index.is_some() { // If this is a known index, we fall back to checking for the realm. if let Some(credentials) = self @@ -632,8 +723,17 @@ impl AuthMiddleware { Some(credentials) }; - self.complete_request(credentials, request, extensions, next, auth_policy) - .await + self.complete_request( + credentials.map(|credentials| FetchedCredentials { + credentials, + cache_scope: CredentialsCacheScope::Realm, + }), + request, + extensions, + next, + auth_policy, + ) + .await } /// Fetch credentials for a URL. @@ -645,7 +745,7 @@ impl AuthMiddleware { url: &DisplaySafeUrl, index: Option<&Index>, auth_policy: AuthPolicy, - ) -> reqwest_middleware::Result>> { + ) -> reqwest_middleware::Result> { let is_s3_endpoint = S3EndpointProvider::is_s3_endpoint(url, self.preview).map_err(Error::Middleware)?; let is_gcs_endpoint = @@ -656,175 +756,297 @@ impl AuthMiddleware { credentials.map(|credentials| credentials.username().unwrap_or_default().to_string()), ); - // Fetches can be expensive, so we will only run them _once_ per realm or index URL and username combination - // All other requests for the same realm or index URL will wait until the first one completes - let key = if let Some(index) = index { - (FetchUrl::Index(index.url.clone()), username) + let provider_url = if let Some(index) = index { + FetchUrl::Index(index.url.clone()) } else { - (FetchUrl::Realm(Realm::from(&**url)), username) + FetchUrl::Realm(Realm::from(&**url)) }; - if let Some(credentials) = self.cache().fetches.register_or_wait(&key).await { - if credentials.is_some() { - trace!("Using credentials from previous fetch for {}", key.0); - } else { + let provider_key = (provider_url, username.clone()); + let keyring_key = provider_key.clone(); + let fetch_providers = match self.cache().fetches.register_or_wait(&provider_key).await { + Some(Some(credentials)) => { trace!( - "Skipping fetch of credentials for {}, previous attempt failed", - key.0 + "Using credentials from previous fetch for `{}`", + provider_key.0 ); + return Ok(Some(credentials)); } + Some(None) => { + trace!( + "Skipping fetch of credentials for `{}`, previous attempt failed", + provider_key.0 + ); + false + } + None => true, + }; - return Ok(credentials); - } - - // Support for known providers, like Hugging Face and S3. - if let Some(credentials) = HuggingFaceProvider::credentials_for(url) - .map(Authentication::from) - .map(Arc::new) - { - debug!("Found Hugging Face credentials for `{url}`"); - self.cache().fetches.done(key, Some(credentials.clone())); - return Ok(Some(credentials)); - } + if fetch_providers { + // Support for known providers, like Hugging Face and S3. + if let Some(credentials) = HuggingFaceProvider::credentials_for(url) + .map(Authentication::from) + .map(Arc::new) + { + debug!("Found Hugging Face credentials for `{url}`"); + let credentials = FetchedCredentials { + credentials, + cache_scope: CredentialsCacheScope::Realm, + }; + self.cache() + .fetches + .done(provider_key, Some(credentials.clone())); + return Ok(Some(credentials)); + } - if is_s3_endpoint { - let mut s3_state = self.s3_credential_state.lock().await; + if is_s3_endpoint { + let mut s3_state = self.s3_credential_state.lock().await; + + // If the S3 credential state is uninitialized, initialize it. + let credentials = match &*s3_state { + S3CredentialState::Uninitialized => { + trace!("Initializing S3 credentials for `{url}`"); + let signer = S3EndpointProvider::create_signer(); + let credentials = Arc::new(Authentication::from(signer)); + *s3_state = S3CredentialState::Initialized(Some(credentials.clone())); + Some(credentials) + } + S3CredentialState::Initialized(credentials) => credentials.clone(), + }; - // If the S3 credential state is uninitialized, initialize it. - let credentials = match &*s3_state { - S3CredentialState::Uninitialized => { - trace!("Initializing S3 credentials for `{url}`"); - let signer = S3EndpointProvider::create_signer(); - let credentials = Arc::new(Authentication::from(signer)); - *s3_state = S3CredentialState::Initialized(Some(credentials.clone())); - Some(credentials) + if let Some(credentials) = credentials { + debug!("Found S3 credentials for `{url}`"); + let credentials = FetchedCredentials { + credentials, + cache_scope: CredentialsCacheScope::Realm, + }; + self.cache() + .fetches + .done(provider_key, Some(credentials.clone())); + return Ok(Some(credentials)); } - S3CredentialState::Initialized(credentials) => credentials.clone(), - }; - - if let Some(credentials) = credentials { - debug!("Found S3 credentials for `{url}`"); - self.cache().fetches.done(key, Some(credentials.clone())); - return Ok(Some(credentials)); } - } - if is_gcs_endpoint { - let mut gcs_state = self.gcs_credential_state.lock().await; + if is_gcs_endpoint { + let mut gcs_state = self.gcs_credential_state.lock().await; + + // If the GCS credential state is uninitialized, initialize it. + let credentials = match &*gcs_state { + GcsCredentialState::Uninitialized => { + trace!("Initializing GCS credentials for `{url}`"); + let signer = GcsEndpointProvider::create_signer(); + let credentials = Arc::new(Authentication::from(signer)); + *gcs_state = GcsCredentialState::Initialized(Some(credentials.clone())); + Some(credentials) + } + GcsCredentialState::Initialized(credentials) => credentials.clone(), + }; - // If the GCS credential state is uninitialized, initialize it. - let credentials = match &*gcs_state { - GcsCredentialState::Uninitialized => { - trace!("Initializing GCS credentials for `{url}`"); - let signer = GcsEndpointProvider::create_signer(); - let credentials = Arc::new(Authentication::from(signer)); - *gcs_state = GcsCredentialState::Initialized(Some(credentials.clone())); - Some(credentials) + if let Some(credentials) = credentials { + debug!("Found GCS credentials for `{url}`"); + let credentials = FetchedCredentials { + credentials, + cache_scope: CredentialsCacheScope::Realm, + }; + self.cache() + .fetches + .done(provider_key, Some(credentials.clone())); + return Ok(Some(credentials)); } - GcsCredentialState::Initialized(credentials) => credentials.clone(), - }; - - if let Some(credentials) = credentials { - debug!("Found GCS credentials for `{url}`"); - self.cache().fetches.done(key, Some(credentials.clone())); - return Ok(Some(credentials)); } - } - if is_azure_endpoint { - let mut azure_state = self.azure_credential_state.lock().await; + if is_azure_endpoint { + let mut azure_state = self.azure_credential_state.lock().await; + + // If the Azure credential state is uninitialized, initialize it. + let credentials = match &*azure_state { + AzureCredentialState::Uninitialized => { + trace!("Initializing Azure credentials for `{url}`"); + let signer = AzureEndpointProvider::create_signer(); + let credentials = Arc::new(Authentication::from(signer)); + *azure_state = AzureCredentialState::Initialized(Some(credentials.clone())); + Some(credentials) + } + AzureCredentialState::Initialized(credentials) => credentials.clone(), + }; - // If the Azure credential state is uninitialized, initialize it. - let credentials = match &*azure_state { - AzureCredentialState::Uninitialized => { - trace!("Initializing Azure credentials for `{url}`"); - let signer = AzureEndpointProvider::create_signer(); - let credentials = Arc::new(Authentication::from(signer)); - *azure_state = AzureCredentialState::Initialized(Some(credentials.clone())); - Some(credentials) + if let Some(credentials) = credentials { + debug!("Found Azure credentials for `{url}`"); + let credentials = FetchedCredentials { + credentials, + cache_scope: CredentialsCacheScope::Realm, + }; + self.cache() + .fetches + .done(provider_key, Some(credentials.clone())); + return Ok(Some(credentials)); } - AzureCredentialState::Initialized(credentials) => credentials.clone(), + } + + // Netrc support based on: . + let provider_credentials = if let Some(credentials) = + self.netrc.get().and_then(|netrc| { + debug!("Checking netrc for credentials for `{url}`"); + Credentials::from_netrc( + netrc, + url, + credentials + .as_ref() + .and_then(|credentials| credentials.username()), + ) + }) { + debug!("Found credentials in netrc file for `{url}`"); + Some(credentials) + } else { + None }; - if let Some(credentials) = credentials { - debug!("Found Azure credentials for `{url}`"); - self.cache().fetches.done(key, Some(credentials.clone())); + if let Some(credentials) = provider_credentials { + let credentials = FetchedCredentials { + credentials: Arc::new(Authentication::from(credentials)), + cache_scope: CredentialsCacheScope::Realm, + }; + self.cache() + .fetches + .done(provider_key, Some(credentials.clone())); return Ok(Some(credentials)); } - } - // Netrc support based on: . - let credentials = if let Some(credentials) = self.netrc.get().and_then(|netrc| { - debug!("Checking netrc for credentials for `{url}`"); - Credentials::from_netrc( - netrc, - url, - credentials - .as_ref() - .and_then(|credentials| credentials.username()), - ) - }) { - debug!("Found credentials in netrc file for `{url}`"); - Some(credentials) + self.cache().fetches.done(provider_key, None); + } - // Text credential store support. - } else if let Some(credentials) = self.text_store.get().await.and_then(|text_store| { + // Text and native stores can scope credentials to a URL path, so consult them for each + // request instead of retaining every path in the process-wide fetch cache. + let native_auth_enabled = self.preview.is_enabled(PreviewFeature::NativeAuth); + let text_store = if native_auth_enabled { + None + } else { + self.text_store.get().await + }; + let path_sensitive_store_enabled = text_store.is_some() || native_auth_enabled; + let realm = Realm::from(url); + let store_credentials = if let Some(text_store) = text_store { debug!("Checking text store for credentials for `{url}`"); - match text_store.get_credentials( - url, - credentials - .as_ref() - .and_then(|credentials| credentials.username()), - ) { - Ok(credentials) => credentials.cloned(), - Err(err) => { - debug!("Failed to get credentials from text store: {err}"); + let snapshot = self + .cache() + .get_stored_snapshot(&realm) + .unwrap_or_else(|| StoredCredentials::from(text_store.realm_credentials(&realm))); + match CredentialsCache::select_stored(&snapshot, url, &username) { + Ok(Some(credentials)) => { + debug!("Found credentials in plaintext store for `{url}`"); + Some(FetchedCredentials { + credentials, + cache_scope: CredentialsCacheScope::Stored(snapshot), + }) + } + Ok(None) => None, + Err(_) => { + debug!("Multiple plaintext credentials match URL `{url}`"); None } } - }) { - debug!("Found credentials in plaintext store for `{url}`"); - Some(credentials) - } else if let Some(credentials) = { - if self.preview.is_enabled(PreviewFeature::NativeAuth) { - let native_store = KeyringProvider::native(); - let username = credentials.and_then(|credentials| credentials.username()); - let display_username = if let Some(username) = username { - format!("{username}@") - } else { - String::new() - }; - if let Some(index) = index { - // N.B. The native store performs an exact look up right now, so we use the root - // URL of the index instead of relying on prefix-matching. - debug!( - "Checking native store for credentials for index URL {}{}", - display_username, index.root_url - ); - native_store.fetch(&index.root_url, username).await + } else if native_auth_enabled { + let display_username = username + .as_deref() + .map_or_else(String::new, |username| format!("{username}@")); + if let Some(index) = index { + debug!( + "Checking native store for credentials for URL `{}{}` in index `{}`", + display_username, url, index.root_url + ); + } else { + debug!( + "Checking native store for credentials for URL `{}{}`", + display_username, url + ); + } + + let snapshot = self + .native_realm_credentials(&realm) + .await + .inspect_err(|err| debug!("Failed to get credentials from native store: {err}")) + .map_err(Self::native_store_error)?; + let select = || { + CredentialsCache::select_stored(&snapshot, url, &username) + .map(|credentials| { + credentials.map(|credentials| FetchedCredentials { + credentials, + cache_scope: CredentialsCacheScope::Stored(snapshot.clone()), + }) + }) + .map_err(|_| { + Self::native_store_error(crate::keyring::Error::AmbiguousUsername( + url.clone(), + )) + }) + }; + let credentials = select()?; + if credentials.is_some() || username.is_none() { + credentials + } else { + // Only a cache miss serializes legacy lookup through publication. Recheck after + // acquiring the lock because another lookup may have populated this account. + let lookup = snapshot.lock_native_lookup().await; + if let Some(credentials) = select()? { + Some(credentials) } else { - debug!( - "Checking native store for credentials for URL `{}{}`", - display_username, url - ); - native_store.fetch(url, username).await + crate::keyring::fetch_native(url, username.as_deref()) + .await + .inspect_err(|err| { + debug!("Failed to get credentials from native store: {err}"); + }) + .map_err(Self::native_store_error)? + .map(|fetched| { + let cache_scope = if let Some(credentials) = fetched.snapshot { + lookup.replace(credentials); + CredentialsCacheScope::Stored(snapshot.clone()) + } else { + CredentialsCacheScope::FetchOnly + }; + FetchedCredentials { + credentials: Arc::new(Authentication::from(fetched.credentials)), + cache_scope, + } + }) } - // TODO(zanieb): We should have a realm fallback here too - } else { - None } - } { - debug!("Found credentials in native store for `{url}`"); - Some(credentials) - // N.B. The keyring provider performs lookups for the exact URL then falls back to the host. - // But, in the absence of an index URL, we cache the result per realm. So in that case, - // if a keyring implementation returns different credentials for different URLs in the - // same realm we will use the wrong credentials. - } else if let Some(credentials) = match self.keyring { + } else { + None + }; + + if store_credentials.is_some() { + return Ok(store_credentials); + } + + let realm_fallback = || { + self.cache() + .get_keyring_realm(realm.clone(), username.clone()) + .map(|credentials| FetchedCredentials { + credentials, + cache_scope: CredentialsCacheScope::FetchOnly, + }) + }; + if index.is_none() + && let Some(credentials) = realm_fallback() + { + return Ok(Some(credentials)); + } + + // The subprocess provider is slow, but its lookup target is realm- or index-scoped. Keep + // its memoization separate so path-sensitive store lookups still run first on every path. + if let Some(credentials) = self + .cache() + .keyring_fetches + .register_or_wait(&keyring_key) + .await + { + return Ok(credentials.or_else(realm_fallback)); + } + + let keyring_credentials = match self.keyring { Some(ref keyring) => { - // The subprocess keyring provider is _slow_ so we do not perform fetches for all - // URLs; instead, we fetch if there's a username or if the user has requested to - // always authenticate. - if let Some(username) = credentials.and_then(|credentials| credentials.username()) { + let credentials = if let Some(username) = + credentials.and_then(|credentials| credentials.username()) + { if let Some(index) = index { debug!( "Checking keyring for credentials for index URL `{}@{}`", @@ -850,29 +1072,37 @@ impl AuthMiddleware { .fetch(DisplaySafeUrl::ref_cast(&index.url), None) .await } else { - None + Ok(None) } } else { debug!( "Skipping keyring fetch for `{url}` without username; use `authenticate = always` to force" ); - None + Ok(None) + }; + match credentials { + Ok(credentials) => credentials, + Err(err) => { + debug!("Failed to get credentials from keyring: {err}"); + None + } } } None => None, - } { - debug!("Found credentials in keyring for `{url}`"); - Some(credentials) - } else { - None }; - let credentials = credentials.map(Authentication::from).map(Arc::new); - - // Register the fetch for this key - self.cache().fetches.done(key, credentials.clone()); - - Ok(credentials) + let keyring_credentials = keyring_credentials.map(|credentials| FetchedCredentials { + credentials: Arc::new(Authentication::from(credentials)), + cache_scope: if path_sensitive_store_enabled { + CredentialsCacheScope::KeyringFallback + } else { + CredentialsCacheScope::Realm + }, + }); + self.cache() + .keyring_fetches + .done(keyring_key, keyring_credentials.clone()); + Ok(keyring_credentials.or_else(realm_fallback)) } } @@ -892,12 +1122,17 @@ fn tracing_url(request: &Request, credentials: Option<&Authentication>) -> Displ #[cfg(test)] mod tests { use std::assert_matches; + use std::future::pending; use std::io::Write; + use std::str::FromStr; + use std::time::Duration; use http::Method; use reqwest::Client; use tempfile::NamedTempFile; use test_log::test; + use tokio::sync::Notify; + use tokio::time::timeout; use url::Url; use wiremock::matchers::{basic_auth, method, path_regex}; @@ -905,6 +1140,7 @@ mod tests { use crate::Index; use crate::credentials::Password; + use crate::persistent::PersistentCredential; use super::*; @@ -963,6 +1199,271 @@ mod tests { Ok(()) } + #[test(tokio::test)] + async fn native_realm_initialization_recovers_after_cancellation() -> Result<(), Error> { + let url = DisplaySafeUrl::parse("https://example.com")?; + let realm = Realm::from(&url); + let cache = Arc::new(CredentialsCache::new()); + let started = Arc::new(Notify::new()); + let loading = { + let cache = cache.clone(); + let realm = realm.clone(); + let started = started.clone(); + tokio::spawn(async move { + cache + .native_realm(&realm, || async move { + started.notify_one(); + Ok(StoredCredentials::from( + pending::>().await, + )) + }) + .await + }) + }; + let initialization = timeout(Duration::from_secs(5), started.notified()).await; + loading.abort(); + assert!( + loading + .await + .expect_err("initialization was aborted") + .is_cancelled() + ); + initialization?; + + let service = crate::Service::from_str(url.as_str())?; + let snapshot = timeout( + Duration::from_secs(5), + cache.native_realm(&realm, || async { + Ok(StoredCredentials::from(vec![PersistentCredential { + service, + credentials: Credentials::basic( + Some("user".to_string()), + Some("password".to_string()), + ), + }])) + }), + ) + .await??; + let credentials = CredentialsCache::select_stored(&snapshot, &url, &Username::none()) + .map_err(|_| "one stored credential must be unambiguous")? + .expect("the second initializer must publish its credentials"); + assert_eq!(credentials.username(), Some("user")); + Ok(()) + } + + #[test(tokio::test)] + async fn native_cached_account_does_not_wait_for_legacy_lookup() -> Result<(), Error> { + let server = start_test_server("user", "password").await; + let mut url = DisplaySafeUrl::parse(&server.uri())?; + let snapshot = StoredCredentials::from(vec![PersistentCredential { + service: crate::Service::from_str(url.as_str())?, + credentials: Credentials::basic(Some("user".to_string()), Some("password".to_string())), + }]); + let cache = Arc::new(CredentialsCache::new()); + cache + .native_realm(&Realm::from(&url), || async { Ok(snapshot.clone()) }) + .await?; + let client = test_client_builder() + .with( + AuthMiddleware::new() + .with_netrc(None) + .with_text_store(None) + .with_preview(Preview::all()) + .with_cache_arc(cache), + ) + .build(); + url.set_username("user").map_err(|()| "invalid username")?; + + // A legacy lookup for another username may remain blocked in the operating system. + let lookup = snapshot.lock_native_lookup().await; + let response = timeout(Duration::from_secs(5), client.get(url.as_str()).send()).await; + drop(lookup); + assert_eq!(response??.status(), 200); + Ok(()) + } + + #[derive(Debug)] + struct PausedAuthenticationRequest { + started: Arc, + release: Arc, + } + + #[async_trait::async_trait] + impl Middleware for PausedAuthenticationRequest { + async fn handle( + &self, + request: Request, + extensions: &mut Extensions, + next: Next<'_>, + ) -> reqwest_middleware::Result { + if request.url().path() == "/first" { + self.started.notify_one(); + self.release.notified().await; + } + next.run(request, extensions).await + } + } + + #[test(tokio::test)] + async fn native_realm_cache_retains_newer_accounts_after_an_older_response() -> Result<(), Error> + { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(basic_auth("first", "first-password")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(basic_auth("second", "second-password")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + let url = DisplaySafeUrl::parse(&server.uri())?; + let service = crate::Service::from_str(url.as_str())?; + let first = crate::persistent::PersistentCredential { + service: service.clone(), + credentials: Credentials::basic( + Some("first".to_string()), + Some("first-password".to_string()), + ), + }; + let second = crate::persistent::PersistentCredential { + service, + credentials: Credentials::basic( + Some("second".to_string()), + Some("second-password".to_string()), + ), + }; + let snapshot = StoredCredentials::from(vec![first.clone()]); + let cache = Arc::new(CredentialsCache::new()); + cache + .native_realm(&Realm::from(&url), || async { Ok(snapshot.clone()) }) + .await?; + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let client = test_client_builder() + .with( + AuthMiddleware::new() + .with_netrc(None) + .with_text_store(None) + .with_preview(Preview::all()) + .with_cache_arc(cache.clone()), + ) + .with(PausedAuthenticationRequest { + started: started.clone(), + release: release.clone(), + }) + .build(); + let mut first_url = url.clone(); + first_url + .set_username("first") + .map_err(|()| "invalid username")?; + first_url.set_path("/first"); + let pending = { + let client = client.clone(); + tokio::spawn(async move { client.get(first_url.as_str()).send().await }) + }; + timeout(Duration::from_secs(5), started.notified()).await?; + + // A later legacy migration discovers a second account before the first response returns. + { + let lookup = snapshot.lock_native_lookup().await; + lookup.replace(vec![first, second]); + } + let mut second_url = url.clone(); + second_url + .set_username("second") + .map_err(|()| "invalid username")?; + second_url.set_path("/second"); + let second_response = timeout( + Duration::from_secs(5), + client.get(second_url.as_str()).send(), + ) + .await; + release.notify_one(); + let first_response = pending.await?; + assert_eq!(second_response??.status(), 200); + assert_eq!(first_response?.status(), 200); + let error = + client.get(server.uri()).send().await.expect_err( + "both accounts must remain ambiguous after the older response completes", + ); + insta::assert_snapshot!(format!("{error:#}").replace(&server.uri(), "[URL]"), @"Failed to fetch credentials from the native credential store: Multiple credentials found for URL '[URL]/', specify which username to use"); + Ok(()) + } + + async fn ambiguous_native_cache(url: &DisplaySafeUrl) -> CredentialsCache { + let cache = CredentialsCache::new(); + let service = crate::Service::from_str(url.as_str()).unwrap(); + let snapshot = StoredCredentials::from(vec![ + crate::persistent::PersistentCredential { + service: service.clone(), + credentials: Credentials::basic( + Some("first".to_string()), + Some("first-password".to_string()), + ), + }, + crate::persistent::PersistentCredential { + service, + credentials: Credentials::basic( + Some("second".to_string()), + Some("second-password".to_string()), + ), + }, + ]); + cache + .native_realm(&Realm::from(url), || async { Ok(snapshot.clone()) }) + .await + .expect("native snapshot should initialize"); + cache.insert_stored(url, snapshot); + cache + } + + #[test(tokio::test)] + async fn cached_native_ambiguity_allows_public_requests() -> Result<(), Error> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + let url = DisplaySafeUrl::parse(&server.uri())?; + let client = test_client_builder() + .with( + AuthMiddleware::new() + .with_preview(Preview::all()) + .with_cache(ambiguous_native_cache(&url).await), + ) + .build(); + assert_eq!(client.get(server.uri()).send().await?.status(), 200); + Ok(()) + } + + #[test(tokio::test)] + async fn cached_native_ambiguity_is_reported_after_challenge() -> Result<(), Error> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(401)) + .expect(1) + .mount(&server) + .await; + let url = DisplaySafeUrl::parse(&server.uri())?; + let client = test_client_builder() + .with( + AuthMiddleware::new() + .with_preview(Preview::all()) + .with_cache(ambiguous_native_cache(&url).await), + ) + .build(); + let error = client.get(server.uri()).send().await.unwrap_err(); + assert!(format!("{error:?}").contains("Multiple credentials found")); + Ok(()) + } + /// Without seeding the cache, authenticated requests are not cached #[test(tokio::test)] async fn test_credentials_in_url_no_seed() -> Result<(), Error> { @@ -2080,6 +2581,7 @@ mod tests { .with( AuthMiddleware::new() .with_cache(CredentialsCache::new()) + .with_text_store(Some(TextCredentialStore::default())) .with_keyring(Some(KeyringProvider::dummy([ (base_url_1.clone(), username, password_1), (base_url_2.clone(), username, password_2), @@ -2482,6 +2984,293 @@ mod tests { Ok(()) } + #[test(tokio::test)] + async fn test_keyring_realm_fallback_with_empty_text_store() -> Result<(), Error> { + let server = start_test_server("user", "password").await; + let base_url = Url::parse(&server.uri())?; + let index_url = base_url.join("private/simple")?; + let client = test_client_builder() + .with( + AuthMiddleware::new() + .with_cache(CredentialsCache::new()) + .with_text_store(Some(TextCredentialStore::default())) + .with_keyring(Some(KeyringProvider::dummy([( + index_url.to_string(), + "user", + "password", + )]))) + .with_indexes(indexes_for(&index_url, AuthPolicy::Always)), + ) + .build(); + assert_eq!(client.get(index_url).send().await?.status(), 200); + assert_eq!( + client + .get(base_url.join("packages/wheel.whl")?) + .send() + .await? + .status(), + 200 + ); + Ok(()) + } + + #[test(tokio::test)] + async fn test_keyring_realm_fallback_does_not_mask_text_store() -> Result<(), Error> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path_regex("/private/simple.*")) + .and(basic_auth("keyring-user", "keyring-password")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path_regex("/packages/private.*")) + .and(basic_auth("stored-user", "stored-password")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + let base_url = Url::parse(&server.uri())?; + let index_url = base_url.join("private/simple")?; + let mut store = TextCredentialStore::default(); + store.insert( + crate::Service::try_from(DisplaySafeUrl::from_url(base_url.join("packages/private")?))?, + Credentials::basic( + Some("stored-user".to_owned()), + Some("stored-password".to_owned()), + ), + ); + let client = test_client_builder() + .with( + AuthMiddleware::new() + .with_cache(CredentialsCache::new()) + .with_text_store(Some(store)) + .with_keyring(Some(KeyringProvider::dummy([( + index_url.to_string(), + "keyring-user", + "keyring-password", + )]))) + .with_indexes(indexes_for(&index_url, AuthPolicy::Always)), + ) + .build(); + assert_eq!(client.get(index_url).send().await?.status(), 200); + assert_eq!( + client + .get(base_url.join("packages/private/wheel.whl")?) + .send() + .await? + .status(), + 200 + ); + Ok(()) + } + + #[test(tokio::test)] + async fn test_text_store_credentials_do_not_mask_more_specific_paths() -> Result<(), Error> { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path_regex("/root$")) + .and(basic_auth("root-user", "root-password")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path_regex("/root/private.*")) + .and(basic_auth("private-user", "private-password")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + + let base_url = Url::parse(&server.uri())?; + let mut store = TextCredentialStore::default(); + store.insert( + crate::Service::try_from(DisplaySafeUrl::from_url(base_url.join("root")?))?, + Credentials::basic( + Some("root-user".to_string()), + Some("root-password".to_string()), + ), + ); + store.insert( + crate::Service::try_from(DisplaySafeUrl::from_url(base_url.join("root/private")?))?, + Credentials::basic( + Some("private-user".to_string()), + Some("private-password".to_string()), + ), + ); + + let client = test_client_builder() + .with( + AuthMiddleware::new() + .with_cache(CredentialsCache::new()) + .with_text_store(Some(store)), + ) + .build(); + + assert_eq!( + client.get(base_url.join("root")?).send().await?.status(), + 200 + ); + assert_eq!( + client + .get(base_url.join("root/private/package")?) + .send() + .await? + .status(), + 200, + "Root credentials must not mask a more-specific credential" + ); + + let requests = server + .received_requests() + .await + .ok_or_else(|| std::io::Error::other("mock server did not record requests"))?; + assert_eq!( + requests.len(), + 3, + "A more-specific cached credential should avoid a second authentication challenge" + ); + + Ok(()) + } + + #[test(tokio::test)] + async fn test_text_store_miss_does_not_mask_another_path() -> Result<(), Error> { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path_regex("/second.*")) + .and(basic_auth("second-user", "second-password")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + + let base_url = Url::parse(&server.uri())?; + let mut store = TextCredentialStore::default(); + store.insert( + crate::Service::try_from(DisplaySafeUrl::from_url(base_url.join("first")?))?, + Credentials::basic( + Some("first-user".to_string()), + Some("first-password".to_string()), + ), + ); + store.insert( + crate::Service::try_from(DisplaySafeUrl::from_url(base_url.join("second")?))?, + Credentials::basic( + Some("second-user".to_string()), + Some("second-password".to_string()), + ), + ); + + let client = test_client_builder() + .with( + AuthMiddleware::new() + .with_cache(CredentialsCache::new()) + .with_text_store(Some(store)), + ) + .build(); + + assert_eq!( + client + .get(base_url.join("first/package")?) + .send() + .await? + .status(), + 401 + ); + assert_eq!( + client + .get(base_url.join("second/package")?) + .send() + .await? + .status(), + 200, + "A failed fetch for another path must not poison this lookup" + ); + + Ok(()) + } + + #[test(tokio::test)] + async fn test_keyring_credentials_do_not_mask_a_more_specific_text_credential() + -> Result<(), Error> { + let server = MockServer::start().await; + let username = "user"; + + Mock::given(method("GET")) + .and(path_regex("/root$")) + .and(basic_auth(username, "keyring-password")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path_regex("/root/private.*")) + .and(basic_auth(username, "text-password")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + + let base_url = Url::parse(&server.uri())?; + let mut store = TextCredentialStore::default(); + store.insert( + crate::Service::try_from(DisplaySafeUrl::from_url(base_url.join("root/private")?))?, + Credentials::basic( + Some(username.to_string()), + Some("text-password".to_string()), + ), + ); + let keyring_service = format!( + "{}:{}", + base_url.host_str().expect("mock server URL has a host"), + base_url.port().expect("mock server URL has a port") + ); + let client = test_client_builder() + .with( + AuthMiddleware::new() + .with_cache(CredentialsCache::new()) + .with_text_store(Some(store)) + .with_keyring(Some(KeyringProvider::dummy([( + keyring_service, + username, + "keyring-password", + )]))), + ) + .build(); + + let mut root_url = base_url.join("root")?; + root_url + .set_username(username) + .expect("HTTP URL accepts a username"); + assert_eq!(client.get(root_url).send().await?.status(), 200); + + let mut private_url = base_url.join("root/private/package")?; + private_url + .set_username(username) + .expect("HTTP URL accepts a username"); + assert_eq!( + client.get(private_url).send().await?.status(), + 200, + "A realm-cached keyring credential must not mask a path-specific text credential" + ); + + Ok(()) + } + #[test(tokio::test)] async fn test_text_store_disabled() -> Result<(), Error> { let username = "user"; diff --git a/crates/uv-auth/src/persistent.rs b/crates/uv-auth/src/persistent.rs new file mode 100644 index 00000000000..8aeaf2fb616 --- /dev/null +++ b/crates/uv-auth/src/persistent.rs @@ -0,0 +1,132 @@ +//! Format-neutral schema for credentials persisted by authentication stores. + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::credentials::{Password, Token, Username}; +use crate::{Credentials, Service}; + +/// Authentication scheme used by a persisted credential. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +enum AuthScheme { + /// HTTP Basic authentication with a username and password. + #[default] + Basic, + /// Bearer authentication with a token. + Bearer, +} + +#[derive(Debug, Error)] +pub(crate) enum BasicAuthError { + #[error("`username` is required with `scheme = basic`")] + MissingUsername, + #[error("`token` cannot be provided with `scheme = basic`")] + UnexpectedToken, +} + +#[derive(Debug, Error)] +pub(crate) enum BearerAuthError { + #[error("`token` is required with `scheme = bearer`")] + MissingToken, + #[error("`username` cannot be provided with `scheme = bearer`")] + UnexpectedUsername, + #[error("`password` cannot be provided with `scheme = bearer`")] + UnexpectedPassword, +} + +/// An invalid persisted credential representation. +#[derive(Debug, Error)] +pub(crate) enum PersistentCredentialError { + #[error(transparent)] + Basic(#[from] BasicAuthError), + #[error(transparent)] + Bearer(#[from] BearerAuthError), +} + +/// A credential entry shared by the text and native stores. +#[derive(Debug, Clone, Deserialize)] +#[serde(try_from = "PersistentCredentialWire")] +pub(crate) struct PersistentCredential { + /// The service URL for this credential. + pub(crate) service: Service, + /// The credentials for this entry. + pub(crate) credentials: Credentials, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PersistentCredentialWire { + service: Service, + username: Username, + #[serde(default)] + scheme: AuthScheme, + password: Option, + token: Option, +} + +impl Serialize for PersistentCredential { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let wire = match &self.credentials { + Credentials::Basic { username, password } => PersistentCredentialWire { + service: self.service.clone(), + username: username.clone(), + scheme: AuthScheme::Basic, + password: password.clone(), + token: None, + }, + Credentials::Bearer { token } => PersistentCredentialWire { + service: self.service.clone(), + username: Username::none(), + scheme: AuthScheme::Bearer, + password: None, + token: Some( + String::from_utf8(token.clone().into_bytes()) + .map_err(serde::ser::Error::custom)?, + ), + }, + }; + wire.serialize(serializer) + } +} + +impl TryFrom for PersistentCredential { + type Error = PersistentCredentialError; + + fn try_from(value: PersistentCredentialWire) -> Result { + let credentials = match value.scheme { + AuthScheme::Basic => { + if value.username.as_deref().is_none() { + return Err(BasicAuthError::MissingUsername.into()); + } + if value.token.is_some() { + return Err(BasicAuthError::UnexpectedToken.into()); + } + Credentials::Basic { + username: value.username, + password: value.password, + } + } + AuthScheme::Bearer => { + if value.username.is_some() { + return Err(BearerAuthError::UnexpectedUsername.into()); + } + if value.password.is_some() { + return Err(BearerAuthError::UnexpectedPassword.into()); + } + let Some(token) = value.token else { + return Err(BearerAuthError::MissingToken.into()); + }; + Credentials::Bearer { + token: Token::new(token.into_bytes()), + } + } + }; + Ok(Self { + service: value.service, + credentials, + }) + } +} diff --git a/crates/uv-auth/src/service.rs b/crates/uv-auth/src/service.rs index a6135ff3f15..71785d01e34 100644 --- a/crates/uv-auth/src/service.rs +++ b/crates/uv-auth/src/service.rs @@ -88,3 +88,12 @@ impl TryFrom for Service { Ok(Self(value)) } } + +impl TryFrom for Service { + type Error = ServiceParseError; + + fn try_from(value: Url) -> Result { + Self::check_scheme(&value)?; + Ok(Self(DisplaySafeUrl::from(value))) + } +} diff --git a/crates/uv-auth/src/store.rs b/crates/uv-auth/src/store.rs index 7459ff6420c..78de4c13251 100644 --- a/crates/uv-auth/src/store.rs +++ b/crates/uv-auth/src/store.rs @@ -1,4 +1,3 @@ -use std::ops::Deref; use std::path::{Path, PathBuf}; use fs_err as fs; @@ -11,12 +10,13 @@ use uv_redacted::DisplaySafeUrl; use uv_state::{StateBucket, StateStore}; use uv_static::EnvVars; +use uv_warnings::warn_user_once; -use crate::credentials::{Password, Token, Username}; -use crate::index::is_path_prefix; -use crate::realm::Realm; +use crate::credentials::Username; +use crate::matching; +use crate::persistent::PersistentCredential; use crate::service::Service; -use crate::{Credentials, KeyringProvider}; +use crate::{Credentials, KeyringProvider, Realm}; /// The storage backend to use in `uv auth` commands. #[derive(Debug)] @@ -32,6 +32,7 @@ impl AuthBackend { pub async fn from_settings(preview: Preview) -> Result { // If preview is enabled, we'll use the system-native store if preview.is_enabled(PreviewFeature::NativeAuth) { + warn_ignored_plaintext_credentials(); return Ok(Self::System(KeyringProvider::native())); } @@ -54,19 +55,16 @@ impl AuthBackend { } } -/// Authentication scheme to use. -#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -enum AuthScheme { - /// HTTP Basic Authentication - /// - /// Uses a username and password. - #[default] - Basic, - /// Bearer token authentication. - /// - /// Uses a token provided as `Bearer ` in the `Authorization` header. - Bearer, +/// Warn when native authentication leaves an existing plaintext credential file unused. +pub(crate) fn warn_ignored_plaintext_credentials() { + if let Ok(path) = TextCredentialStore::default_file() + && path.is_file() + { + warn_user_once!( + "Ignoring plaintext credentials at {} because native authentication is enabled", + path.display() + ); + } } /// Errors that can occur when working with TOML credential storage. @@ -80,14 +78,8 @@ pub enum TomlCredentialError { ParseError(#[from] toml::de::Error), #[error("Failed to serialize credentials to TOML")] SerializeError(#[from] toml::ser::Error), - #[error(transparent)] - BasicAuthError(#[from] BasicAuthError), - #[error(transparent)] - BearerAuthError(#[from] BearerAuthError), #[error("Failed to determine credentials directory")] CredentialsDirError, - #[error("Token is not valid unicode")] - TokenNotUnicode(#[from] std::string::FromUtf8Error), } impl TomlCredentialError { @@ -95,144 +87,22 @@ impl TomlCredentialError { match self { Self::Io(err) => Some(err), Self::LockedFile(err) => err.as_io_error(), - Self::ParseError(_) - | Self::SerializeError(_) - | Self::BasicAuthError(_) - | Self::BearerAuthError(_) - | Self::CredentialsDirError - | Self::TokenNotUnicode(_) => None, + Self::ParseError(_) | Self::SerializeError(_) | Self::CredentialsDirError => None, } } } -#[derive(Debug, Error)] -pub enum BasicAuthError { - #[error("`username` is required with `scheme = basic`")] - MissingUsername, - #[error("`token` cannot be provided with `scheme = basic`")] - UnexpectedToken, -} - -#[derive(Debug, Error)] -pub enum BearerAuthError { - #[error("`token` is required with `scheme = bearer`")] - MissingToken, - #[error("`username` cannot be provided with `scheme = bearer`")] - UnexpectedUsername, - #[error("`password` cannot be provided with `scheme = bearer`")] - UnexpectedPassword, -} - #[derive(Debug, Error, PartialEq)] pub enum LookupError { #[error("Multiple credentials found for URL `{0}`, specify which username to use")] AmbiguousUsername(DisplaySafeUrl), } -/// A single credential entry in a TOML credentials file. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(try_from = "TomlCredentialWire", into = "TomlCredentialWire")] -struct TomlCredential { - /// The service URL for this credential. - service: Service, - /// The credentials for this entry. - credentials: Credentials, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct TomlCredentialWire { - /// The service URL for this credential. - service: Service, - /// The username to use. Only allowed with [`AuthScheme::Basic`]. - username: Username, - /// The authentication scheme. - #[serde(default)] - scheme: AuthScheme, - /// The password to use. Only allowed with [`AuthScheme::Basic`]. - password: Option, - /// The token to use. Only allowed with [`AuthScheme::Bearer`]. - token: Option, -} - -impl From for TomlCredentialWire { - fn from(value: TomlCredential) -> Self { - match value.credentials { - Credentials::Basic { username, password } => Self { - service: value.service, - username, - scheme: AuthScheme::Basic, - password, - token: None, - }, - Credentials::Bearer { token } => Self { - service: value.service, - username: Username::new(None), - scheme: AuthScheme::Bearer, - password: None, - token: Some(String::from_utf8(token.into_bytes()).expect("Token is valid UTF-8")), - }, - } - } -} - -impl TryFrom for TomlCredential { - type Error = TomlCredentialError; - - fn try_from(value: TomlCredentialWire) -> Result { - match value.scheme { - AuthScheme::Basic => { - if value.username.as_deref().is_none() { - return Err(TomlCredentialError::BasicAuthError( - BasicAuthError::MissingUsername, - )); - } - if value.token.is_some() { - return Err(TomlCredentialError::BasicAuthError( - BasicAuthError::UnexpectedToken, - )); - } - let credentials = Credentials::Basic { - username: value.username, - password: value.password, - }; - Ok(Self { - service: value.service, - credentials, - }) - } - AuthScheme::Bearer => { - if value.username.is_some() { - return Err(TomlCredentialError::BearerAuthError( - BearerAuthError::UnexpectedUsername, - )); - } - if value.password.is_some() { - return Err(TomlCredentialError::BearerAuthError( - BearerAuthError::UnexpectedPassword, - )); - } - if value.token.is_none() { - return Err(TomlCredentialError::BearerAuthError( - BearerAuthError::MissingToken, - )); - } - let credentials = Credentials::Bearer { - token: Token::new(value.token.unwrap().into_bytes()), - }; - Ok(Self { - service: value.service, - credentials, - }) - } - } - } -} - #[derive(Debug, Clone, Serialize, Deserialize, Default)] -struct TomlCredentials { +struct PersistentCredentials { /// Array of credential entries. #[serde(default, rename = "credential")] - credentials: Vec, + credentials: Vec, } /// A credential store with a plain text storage backend. @@ -272,7 +142,7 @@ impl TextCredentialStore { /// Read credentials from a file. fn from_file>(path: P) -> Result { let content = fs::read_to_string(path)?; - let credentials: TomlCredentials = toml::from_str(&content)?; + let credentials: PersistentCredentials = toml::from_str(&content)?; let credentials: FxHashMap<(Service, Username), Credentials> = credentials .credentials @@ -317,13 +187,13 @@ impl TextCredentialStore { let credentials = self .credentials .into_iter() - .map(|((service, _username), credentials)| TomlCredential { + .map(|((service, _username), credentials)| PersistentCredential { service, credentials, }) .collect::>(); - let toml_creds = TomlCredentials { credentials }; + let toml_creds = PersistentCredentials { credentials }; let content = toml::to_string_pretty(&toml_creds)?; fs::create_dir_all( path.as_ref() @@ -338,64 +208,34 @@ impl TextCredentialStore { /// Get credentials for a given URL and username. /// - /// The most specific URL prefix match in the same [`Realm`] is returned, if any. + /// The most specific URL prefix match in the same [`crate::Realm`] is returned, if any. pub fn get_credentials( &self, url: &DisplaySafeUrl, username: Option<&str>, ) -> Result, LookupError> { - let request_realm = Realm::from(url); - - // Perform an exact lookup first - // TODO(zanieb): Consider adding `DisplaySafeUrlRef` so we can avoid this clone - // TODO(zanieb): We could also return early here if we can't normalize to a `Service` - if let Ok(url_service) = Service::try_from(url.clone()) { - if let Some(credential) = self - .credentials - .get(&(url_service, Username::from(username.map(str::to_string)))) - { - return Ok(Some(credential)); - } - } - - // If that fails, iterate through to find a prefix match - let mut best: Option<(usize, &Service, &Credentials)> = None; - - for ((service, stored_username), credential) in &self.credentials { - let service_realm = Realm::from(service.url().deref()); - - // Only consider services in the same realm - if service_realm != request_realm { - continue; - } - - // Service path must be a prefix of the request path. - if !is_path_prefix(service.url().path(), url.path()) { - continue; - } - - // If a username is provided, it must match - if let Some(request_username) = username { - if Some(request_username) != stored_username.as_deref() { - continue; - } - } - - // Update our best matching credential based on prefix length - let specificity = service.url().path().len(); - if best.is_none_or(|(best_specificity, _, _)| specificity > best_specificity) { - best = Some((specificity, service, credential)); - } else if best.is_some_and(|(best_specificity, _, _)| specificity == best_specificity) { - return Err(LookupError::AmbiguousUsername(url.clone())); - } - } - - // Return the most specific match - if let Some((_, _, credential)) = best { - return Ok(Some(credential)); - } + matching::select_credential( + self.credentials + .iter() + .map(|((service, stored_username), credentials)| { + (service, stored_username.as_deref(), credentials) + }), + url, + username, + ) + .map_err(|_| LookupError::AmbiguousUsername(url.clone())) + } - Ok(None) + /// Return every stored credential belonging to one authentication realm. + pub(crate) fn realm_credentials(&self, realm: &Realm) -> Vec { + self.credentials + .iter() + .filter(|((service, _username), _credentials)| Realm::from(service.url()) == *realm) + .map(|((service, _username), credentials)| PersistentCredential { + service: service.clone(), + credentials: credentials.clone(), + }) + .collect() } /// Store credentials for a given service. @@ -422,19 +262,20 @@ mod tests { use tempfile::NamedTempFile; use super::*; + use crate::credentials::Password; #[test] fn test_toml_serialization() { - let credentials = TomlCredentials { + let credentials = PersistentCredentials { credentials: vec![ - TomlCredential { + PersistentCredential { service: Service::from_str("https://example.com").unwrap(), credentials: Credentials::Basic { username: Username::new(Some("user1".to_string())), password: Some(Password::new("pass1".to_string())), }, }, - TomlCredential { + PersistentCredential { service: Service::from_str("https://test.org").unwrap(), credentials: Credentials::Basic { username: Username::new(Some("user2".to_string())), @@ -445,7 +286,7 @@ mod tests { }; let toml_str = toml::to_string_pretty(&credentials).unwrap(); - let parsed: TomlCredentials = toml::from_str(&toml_str).unwrap(); + let parsed: PersistentCredentials = toml::from_str(&toml_str).unwrap(); assert_eq!(parsed.credentials.len(), 2); assert_eq!( @@ -634,6 +475,30 @@ password = "pass2" assert_eq!(cred.username(), Some("general")); } + #[test] + fn test_more_specific_match_wins_over_less_specific_ambiguity() { + let mut store = TextCredentialStore::default(); + let root_service = Service::from_str("https://example.com").unwrap(); + let api_service = Service::from_str("https://example.com/api").unwrap(); + + store.insert( + root_service.clone(), + Credentials::basic(Some("root-a".to_string()), Some("pass-a".to_string())), + ); + store.insert( + root_service, + Credentials::basic(Some("root-b".to_string()), Some("pass-b".to_string())), + ); + store.insert( + api_service, + Credentials::basic(Some("api".to_string()), Some("api-pass".to_string())), + ); + + let url = DisplaySafeUrl::parse("https://example.com/api/child").unwrap(); + let credentials = store.get_credentials(&url, None).unwrap().unwrap(); + assert_eq!(credentials.username(), Some("api")); + } + #[test] fn test_username_exact_url_match() { let mut store = TextCredentialStore::default(); @@ -699,6 +564,31 @@ password = "pass2" assert_eq!(result.unwrap().username(), Some("specific_user")); } + #[test] + fn test_prefix_matching_respects_path_boundaries() { + let mut store = TextCredentialStore::default(); + let service = Service::from_str("https://example.com/api").unwrap(); + let credentials = + Credentials::basic(Some("user".to_string()), Some("password".to_string())); + store.insert(service, credentials); + + let child_url = DisplaySafeUrl::parse("https://example.com/api/v1").unwrap(); + assert!( + store + .get_credentials(&child_url, Some("user")) + .unwrap() + .is_some() + ); + + let sibling_url = DisplaySafeUrl::parse("https://example.com/apiv1").unwrap(); + assert!( + store + .get_credentials(&sibling_url, Some("user")) + .unwrap() + .is_none() + ); + } + #[test] fn test_ambiguous_username_error() { let mut store = TextCredentialStore::default(); diff --git a/crates/uv-auth/tests/native_cache.rs b/crates/uv-auth/tests/native_cache.rs new file mode 100644 index 00000000000..ced11b58c9f --- /dev/null +++ b/crates/uv-auth/tests/native_cache.rs @@ -0,0 +1,219 @@ +#![cfg(all( + feature = "native-auth", + any(target_os = "macos", target_os = "windows") +))] + +use std::sync::Arc; + +use uv_auth::{AuthBackend, AuthMiddleware, Credentials, CredentialsCache}; +use uv_preview::{MaybePreviewFeature, Preview, PreviewFeature}; +use uv_redacted::DisplaySafeUrl; +use wiremock::matchers::{basic_auth, method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[test_log::test(tokio::test)] +async fn native_credentials_are_cached_by_service_path() -> Result<(), Box> { + let preview = + Preview::from_feature_names([&MaybePreviewFeature::Known(PreviewFeature::NativeAuth)]); + let provider = match AuthBackend::from_settings(preview).await? { + AuthBackend::System(provider) => provider, + AuthBackend::TextStore(..) => { + return Err(std::io::Error::other("expected native authentication backend").into()); + } + }; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/root")) + .and(basic_auth("root-user", "root-password")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path_regex("/root/private.*")) + .and(basic_auth("private-user", "private-password")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + + let root = DisplaySafeUrl::parse(&format!("{}/root", server.uri()))?; + let private = DisplaySafeUrl::parse(&format!("{}/root/private", server.uri()))?; + let result = async { + provider + .store( + &root, + &Credentials::basic( + Some("root-user".to_string()), + Some("root-password".to_string()), + ), + ) + .await?; + provider + .store( + &private, + &Credentials::basic( + Some("private-user".to_string()), + Some("private-password".to_string()), + ), + ) + .await?; + + let cache = Arc::new(CredentialsCache::new()); + let first_client = reqwest_middleware::ClientBuilder::new(reqwest::Client::new()) + .with( + AuthMiddleware::new() + .with_cache_arc(cache.clone()) + .with_preview(preview), + ) + .build(); + let second_client = reqwest_middleware::ClientBuilder::new(reqwest::Client::new()) + .with( + AuthMiddleware::new() + .with_cache_arc(cache) + .with_preview(preview), + ) + .build(); + + if first_client.get(root.as_str()).send().await?.status() != 200 { + return Err(std::io::Error::other("root credential did not authenticate").into()); + } + + // A second request must use the complete realm snapshot, not reload the keyring or reuse + // the broader root credential. + provider.remove(&root, "root-user").await?; + provider.remove(&private, "private-user").await?; + + if second_client + .get(format!("{private}/package")) + .send() + .await? + .status() + != 200 + { + return Err(std::io::Error::other( + "the cached realm did not retain the more-specific credential", + ) + .into()); + } + + let requests = server + .received_requests() + .await + .ok_or_else(|| std::io::Error::other("mock server did not record requests"))?; + if requests.len() != 3 { + return Err(std::io::Error::other( + "only the first request should require an authentication challenge", + ) + .into()); + } + + Ok::<(), Box>(()) + } + .await; + + let _ = provider.remove(&root, "root-user").await; + let _ = provider.remove(&private, "private-user").await; + + result +} + +#[test_log::test(tokio::test)] +async fn migrated_native_credentials_are_cached_by_realm() -> Result<(), Box> +{ + let preview = + Preview::from_feature_names([&MaybePreviewFeature::Known(PreviewFeature::NativeAuth)]); + let provider = match AuthBackend::from_settings(preview).await? { + AuthBackend::System(provider) => provider, + AuthBackend::TextStore(..) => { + return Err(std::io::Error::other("expected native authentication backend").into()); + } + }; + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path_regex("/private.*")) + .and(basic_auth("legacy-user", "legacy-password")) + .respond_with(ResponseTemplate::new(200)) + .with_priority(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(401)) + .with_priority(2) + .mount(&server) + .await; + + let realm = DisplaySafeUrl::parse(&server.uri())?; + let private = DisplaySafeUrl::parse(&format!("{}/private", server.uri()))?; + // An HTTP scheme-qualified host entry can migrate to its exact realm. Exact request-URL + // entries remain legacy entries because their intended broader scope is unknown. + let legacy = uv_keyring::Entry::new(&format!("uv:{}", server.uri()), "legacy-user")?; + let other = MockServer::start().await; + Mock::given(method("GET")) + .and(basic_auth("legacy-user", "legacy-password")) + .respond_with(ResponseTemplate::new(200)) + .with_priority(1) + .mount(&other) + .await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(401)) + .with_priority(2) + .mount(&other) + .await; + let result = async { + legacy.set_password("legacy-password").await?; + let cache = Arc::new(CredentialsCache::new()); + let first_client = reqwest_middleware::ClientBuilder::new(reqwest::Client::new()) + .with( + AuthMiddleware::new() + .with_cache_arc(Arc::clone(&cache)) + .with_preview(preview), + ) + .build(); + let second_client = reqwest_middleware::ClientBuilder::new(reqwest::Client::new()) + .with( + AuthMiddleware::new() + .with_cache_arc(cache) + .with_preview(preview), + ) + .build(); + let mut request = private.clone(); + request + .set_username("legacy-user") + .map_err(|()| std::io::Error::other("invalid username"))?; + if first_client.get(request.as_str()).send().await?.status() != 200 { + return Err(std::io::Error::other("legacy credential did not authenticate").into()); + } + if !matches!(legacy.get_password().await, Err(uv_keyring::Error::NoEntry)) { + return Err(std::io::Error::other("legacy credential was not migrated").into()); + } + + // Removing the migrated entry makes any subsequent native lookup fail. Shared clients + // must retain the migrated service scope in their cached realm snapshot. + provider + .remove(&realm, "legacy-user") + .await + .map_err(|error| { + std::io::Error::other(format!( + "could not remove migrated realm credential: {error}" + )) + })?; + request.set_path("/private/package"); + if second_client.get(request.as_str()).send().await?.status() != 200 { + return Err(std::io::Error::other("migrated realm credential was not cached").into()); + } + if second_client.get(other.uri()).send().await?.status() != 401 { + return Err( + std::io::Error::other("realm credential was used for another origin").into(), + ); + } + Ok::<(), Box>(()) + } + .await; + let _ = provider.remove(&realm, "legacy-user").await; + let _ = legacy.delete_credential().await; + result +} diff --git a/crates/uv-auth/tests/native_macos.rs b/crates/uv-auth/tests/native_macos.rs new file mode 100644 index 00000000000..3e0cdaac2fb --- /dev/null +++ b/crates/uv-auth/tests/native_macos.rs @@ -0,0 +1,120 @@ +#![cfg(all(feature = "native-auth", target_os = "macos"))] + +use std::time::{SystemTime, UNIX_EPOCH}; + +use uv_auth::{AuthBackend, Credentials}; +use uv_preview::{MaybePreviewFeature, Preview, PreviewFeature}; +use uv_redacted::DisplaySafeUrl; + +/// Return the native provider used by the preview authentication backend. +async fn native_provider() -> Result> { + let preview = + Preview::from_feature_names([&MaybePreviewFeature::Known(PreviewFeature::NativeAuth)]); + match AuthBackend::from_settings(preview).await? { + AuthBackend::System(provider) => Ok(provider), + AuthBackend::TextStore(..) => { + Err(std::io::Error::other("expected native authentication backend").into()) + } + } +} + +#[tokio::test] +async fn native_store_migrates_https_legacy_host_credentials() +-> Result<(), Box> { + let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let host = format!("native-legacy-{unique}.example.invalid"); + let username = "legacy-user"; + let password = "legacy-password"; + let legacy = uv_keyring::Entry::new(&format!("uv:{host}"), username)?; + legacy.set_password(password).await?; + let provider = native_provider().await?; + let request = DisplaySafeUrl::parse(&format!("https://{host}/first"))?; + let migrated = DisplaySafeUrl::parse(&format!("https://{host}/"))?; + + let result = async { + let expected = Some(Credentials::basic( + Some(username.to_string()), + Some(password.to_string()), + )); + if provider.fetch(&request, Some(username)).await? != expected { + return Err(std::io::Error::other("legacy credential was not returned").into()); + } + if !matches!(legacy.get_password().await, Err(uv_keyring::Error::NoEntry)) { + return Err(std::io::Error::other("legacy credential was not removed").into()); + } + if provider.fetch(&migrated, Some(username)).await?.is_none() { + return Err(std::io::Error::other("migrated credential was not stored").into()); + } + Ok::<(), Box>(()) + } + .await; + + let _ = provider.remove(&migrated, username).await; + let _ = legacy.delete_credential().await; + result +} + +#[tokio::test] +async fn native_store_migrates_http_legacy_uv_username_without_collection_collision() +-> Result<(), Box> { + let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let port = 10_000 + (unique % 50_000) as u16; + let service = format!("http://localhost:{port}"); + let username = "uv"; + let password = "legacy-password"; + let legacy = uv_keyring::Entry::new(&format!("uv:{service}"), username)?; + legacy.set_password(password).await?; + let provider = native_provider().await?; + let request = DisplaySafeUrl::parse(&format!("{service}/first"))?; + let migrated = DisplaySafeUrl::parse(&format!("{service}/"))?; + + let result = async { + let expected = Some(Credentials::basic( + Some(username.to_string()), + Some(password.to_string()), + )); + if provider.fetch(&request, Some(username)).await? != expected { + return Err(std::io::Error::other("legacy credential was not returned").into()); + } + if !matches!(legacy.get_password().await, Err(uv_keyring::Error::NoEntry)) { + return Err(std::io::Error::other("legacy credential was not removed").into()); + } + if provider.fetch(&migrated, Some(username)).await? != expected { + return Err(std::io::Error::other("migrated credential was not stored").into()); + } + Ok::<(), Box>(()) + } + .await; + + let _ = provider.remove(&migrated, username).await; + let _ = legacy.delete_credential().await; + result +} + +#[tokio::test] +async fn native_store_does_not_migrate_http_legacy_host_credentials() +-> Result<(), Box> { + let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let port = 10_000 + (unique % 50_000) as u16; + let service = format!("localhost:{port}"); + let username = "legacy-user"; + let password = "legacy-password"; + let legacy = uv_keyring::Entry::new(&format!("uv:{service}"), username)?; + legacy.set_password(password).await?; + let provider = native_provider().await?; + let request = DisplaySafeUrl::parse(&format!("http://{service}/first"))?; + + let result = async { + if provider.fetch(&request, Some(username)).await?.is_none() { + return Err(std::io::Error::other("legacy credential was not returned").into()); + } + if legacy.get_password().await? != password { + return Err(std::io::Error::other("legacy credential changed unexpectedly").into()); + } + Ok::<(), Box>(()) + } + .await; + + let _ = legacy.delete_credential().await; + result +} diff --git a/crates/uv-auth/tests/native_windows.rs b/crates/uv-auth/tests/native_windows.rs new file mode 100644 index 00000000000..1d8313e8408 --- /dev/null +++ b/crates/uv-auth/tests/native_windows.rs @@ -0,0 +1,290 @@ +#![cfg(all(feature = "native-auth", target_os = "windows"))] + +use std::time::{SystemTime, UNIX_EPOCH}; + +use uv_auth::{AuthBackend, Credentials}; +use uv_keyring::windows::WinCredential; +use uv_preview::{MaybePreviewFeature, Preview, PreviewFeature}; +use uv_redacted::DisplaySafeUrl; + +#[tokio::test] +async fn native_store_enumerates_many_credentials_in_one_realm() +-> Result<(), Box> { + let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let preview = + Preview::from_feature_names([&MaybePreviewFeature::Known(PreviewFeature::NativeAuth)]); + let provider = match AuthBackend::from_settings(preview).await? { + AuthBackend::System(provider) => provider, + AuthBackend::TextStore(..) => { + return Err(std::io::Error::other("expected native authentication backend").into()); + } + }; + let entries = (0..16) + .map(|index| { + let url = DisplaySafeUrl::parse(&format!( + "https://native-auth-{unique}.example.invalid/credential-{index}" + ))?; + let credentials = Credentials::basic( + Some(format!("user-{index}")), + Some(format!("{index:02}{}", "x".repeat(1_000))), + ); + Ok::<_, uv_redacted::DisplaySafeUrlError>((url, credentials)) + }) + .collect::, _>>()?; + let result = async { + for (url, credentials) in &entries { + provider.store(url, credentials).await?; + } + for (url, credentials) in &entries { + if provider.fetch(url, credentials.username()).await? != Some(credentials.clone()) { + return Err(std::io::Error::other("unexpected stored credentials").into()); + } + } + Ok::<(), Box>(()) + } + .await; + for (url, credentials) in &entries { + if let Some(username) = credentials.username() { + let _ = provider.remove(url, username).await; + } + } + result +} + +#[tokio::test] +async fn native_store_distinguishes_account_name_collisions() +-> Result<(), Box> { + let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let preview = + Preview::from_feature_names([&MaybePreviewFeature::Known(PreviewFeature::NativeAuth)]); + let provider = match AuthBackend::from_settings(preview).await? { + AuthBackend::System(provider) => provider, + AuthBackend::TextStore(..) => { + return Err(std::io::Error::other("expected native authentication backend").into()); + } + }; + let url = DisplaySafeUrl::parse(&format!( + "https://native-auth-case-{unique}.example.invalid/credentials" + ))?; + let first = Credentials::basic(Some("aa".to_owned()), Some("first".to_owned())); + let second = Credentials::basic(Some("aG".to_owned()), Some("second".to_owned())); + let result = async { + provider.store(&url, &first).await?; + provider.store(&url, &second).await?; + if provider.fetch(&url, Some("aa")).await? != Some(first) { + return Err(std::io::Error::other("unexpected stored credentials").into()); + } + if provider.fetch(&url, Some("aG")).await? != Some(second) { + return Err(std::io::Error::other("unexpected stored credentials").into()); + } + Ok::<(), Box>(()) + } + .await; + let _ = provider.remove(&url, "aa").await; + let _ = provider.remove(&url, "aG").await; + result +} + +#[tokio::test] +async fn native_store_distinguishes_signed_url_identities() -> Result<(), Box> +{ + let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let preview = + Preview::from_feature_names([&MaybePreviewFeature::Known(PreviewFeature::NativeAuth)]); + let provider = match AuthBackend::from_settings(preview).await? { + AuthBackend::System(provider) => provider, + AuthBackend::TextStore(..) => { + return Err(std::io::Error::other("expected native authentication backend").into()); + } + }; + let first_url = DisplaySafeUrl::parse(&format!( + "https://native-auth-signed-{unique}.example.invalid/signed?X-Amz-Signature=one" + ))?; + let second_url = DisplaySafeUrl::parse(&format!( + "https://native-auth-signed-{unique}.example.invalid/signed?X-Amz-Signature=two" + ))?; + let first = Credentials::basic(Some("signed".to_owned()), Some("first".to_owned())); + let second = Credentials::basic(Some("signed".to_owned()), Some("second".to_owned())); + let result = async { + provider.store(&first_url, &first).await?; + provider.store(&second_url, &second).await?; + if provider.fetch(&first_url, Some("signed")).await? != Some(first.clone()) { + return Err(std::io::Error::other("unexpected stored credentials").into()); + } + let mut username_url = first_url.clone(); + username_url + .set_username("signed") + .map_err(|()| std::io::Error::other("invalid username"))?; + if provider.fetch(&username_url, Some("signed")).await? != Some(first) { + return Err( + std::io::Error::other("URL username changed the exact credential match").into(), + ); + } + if provider.fetch(&second_url, Some("signed")).await? != Some(second) { + return Err(std::io::Error::other("unexpected stored credentials").into()); + } + Ok::<(), Box>(()) + } + .await; + let _ = provider.remove(&first_url, "signed").await; + let _ = provider.remove(&second_url, "signed").await; + result +} + +#[tokio::test] +async fn native_store_ignores_corrupt_realm_entries() -> Result<(), Box> { + let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let preview = + Preview::from_feature_names([&MaybePreviewFeature::Known(PreviewFeature::NativeAuth)]); + let provider = match AuthBackend::from_settings(preview).await? { + AuthBackend::System(provider) => provider, + AuthBackend::TextStore(..) => { + return Err(std::io::Error::other("expected native authentication backend").into()); + } + }; + let valid_url = DisplaySafeUrl::parse(&format!( + "https://native-auth-corrupt-{unique}.example.invalid/valid" + ))?; + let corrupt_url = DisplaySafeUrl::parse(&format!( + "https://native-auth-corrupt-{unique}.example.invalid/corrupt" + ))?; + let valid = Credentials::basic(Some("valid".to_owned()), Some("valid-password".to_owned())); + let corrupt = Credentials::basic( + Some("corrupt".to_owned()), + Some("corrupt-password".to_owned()), + ); + let result = async { + provider.store(&valid_url, &valid).await?; + provider.store(&corrupt_url, &corrupt).await?; + let target_prefix = format!("uv:https://native-auth-corrupt-{unique}.example.invalid:"); + let credential = WinCredential::enumerate(&target_prefix) + .await? + .into_iter() + .find(|credential| { + serde_json::from_slice::(credential.secret()) + .ok() + .and_then(|value| { + value + .get("service") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .as_deref() + == Some(corrupt_url.as_str()) + }) + .ok_or_else(|| std::io::Error::other("failed to enumerate test credential"))?; + let entry = + uv_keyring::Entry::new_with_credential(Box::new(credential.credential().clone())); + entry.set_secret(b"not JSON").await?; + let fetched = provider.fetch(&valid_url, Some("valid")).await; + let _ = entry.delete_credential().await; + if fetched? != Some(valid) { + return Err(std::io::Error::other("corrupt entry hid valid credentials").into()); + } + Ok::<(), Box>(()) + } + .await; + let _ = provider.remove(&valid_url, "valid").await; + let _ = provider.remove(&corrupt_url, "corrupt").await; + result +} + +#[tokio::test] +async fn native_store_removes_credentials() -> Result<(), Box> { + let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let preview = + Preview::from_feature_names([&MaybePreviewFeature::Known(PreviewFeature::NativeAuth)]); + let provider = match AuthBackend::from_settings(preview).await? { + AuthBackend::System(provider) => provider, + AuthBackend::TextStore(..) => { + return Err(std::io::Error::other("expected native authentication backend").into()); + } + }; + let url = DisplaySafeUrl::parse(&format!( + "https://native-auth-remove-{unique}.example.invalid/credentials" + ))?; + let credentials = Credentials::basic(Some("user".to_owned()), Some("password".to_owned())); + let result = async { + provider.store(&url, &credentials).await?; + let actual = provider.fetch(&url, Some("user")).await?; + if actual.as_ref() != Some(&credentials) { + return Err(std::io::Error::other(format!( + "unexpected stored credentials: {actual:?}; expected {credentials:?}" + )) + .into()); + } + provider.remove(&url, "user").await?; + if provider.fetch(&url, Some("user")).await?.is_some() { + return Err(std::io::Error::other("removed credential was still returned").into()); + } + Ok::<(), Box>(()) + } + .await; + let _ = provider.remove(&url, "user").await; + result +} + +/// Hashed native targets support URLs that exceed the legacy Windows credential comment limit. +#[tokio::test] +async fn native_store_removes_long_service_url() -> Result<(), Box> { + let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let preview = + Preview::from_feature_names([&MaybePreviewFeature::Known(PreviewFeature::NativeAuth)]); + let provider = match AuthBackend::from_settings(preview).await? { + AuthBackend::System(provider) => provider, + AuthBackend::TextStore(..) => { + return Err(std::io::Error::other("expected native backend").into()); + } + }; + let url = DisplaySafeUrl::parse(&format!( + "https://native-long-{unique}.example.invalid/{}", + "x".repeat(200) + ))?; + let credentials = Credentials::basic(Some("long-url".to_owned()), Some("password".to_owned())); + let result = async { + provider.store(&url, &credentials).await?; + assert_eq!( + provider.fetch(&url, Some("long-url")).await?, + Some(credentials) + ); + provider.remove(&url, "long-url").await?; + assert_eq!(provider.fetch(&url, Some("long-url")).await?, None); + Ok::<_, Box>(()) + } + .await; + let _ = provider.remove(&url, "long-url").await; + result +} + +/// An unrepresentable exact legacy URL must not suppress a valid bare-host fallback. +#[tokio::test] +async fn native_long_url_uses_legacy_host_fallback() -> Result<(), Box> { + let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let preview = + Preview::from_feature_names([&MaybePreviewFeature::Known(PreviewFeature::NativeAuth)]); + let provider = match AuthBackend::from_settings(preview).await? { + AuthBackend::System(provider) => provider, + AuthBackend::TextStore(..) => { + return Err(std::io::Error::other("expected native backend").into()); + } + }; + let host = format!("native-long-fallback-{unique}.example.invalid"); + let url = DisplaySafeUrl::parse(&format!("https://{host}/{}", "x".repeat(200)))?; + let realm = DisplaySafeUrl::parse(&format!("https://{host}/"))?; + let entry = uv_keyring::Entry::new(&format!("uv:{host}"), "legacy")?; + let result = async { + entry.set_password("legacy-password").await?; + assert_eq!( + provider.fetch(&url, Some("legacy")).await?, + Some(Credentials::basic( + Some("legacy".to_owned()), + Some("legacy-password".to_owned()) + )) + ); + Ok::<_, Box>(()) + } + .await; + let _ = entry.delete_credential().await; + let _ = provider.remove(&realm, "legacy").await; + result +} diff --git a/crates/uv-keyring/src/blocking.rs b/crates/uv-keyring/src/blocking.rs index eae0a7c9ccb..b8c44b3bd2e 100644 --- a/crates/uv-keyring/src/blocking.rs +++ b/crates/uv-keyring/src/blocking.rs @@ -1,11 +1,80 @@ -use crate::error::{Error as ErrorCode, Result}; +use std::future::Future; +use std::sync::Arc; +use crate::error::Result; + +tokio::task_local! { + static OPERATION_GUARD: Arc; +} + +/// Retain a lock while a keyring operation and any native blocking work it starts are running. +/// +/// Blocking work keeps its own guard reference, so cancelling the calling future cannot release +/// the lock while the platform is still mutating credentials. +pub async fn with_operation_guard( + guard: Arc, + operation: impl Future>, +) -> Result +where + G: Send + Sync + 'static, +{ + OPERATION_GUARD.scope(guard, operation).await +} + +#[cfg(any( + test, + all(target_os = "macos", feature = "apple-native"), + all(target_os = "windows", feature = "windows-native"), +))] pub(crate) async fn spawn_blocking(f: F) -> Result where F: FnOnce() -> Result + Send + 'static, T: Send + 'static, { - tokio::task::spawn_blocking(f) - .await - .map_err(|e| ErrorCode::PlatformFailure(Box::new(e)))? + let guard = OPERATION_GUARD.try_with(Arc::clone).ok(); + tokio::task::spawn_blocking(move || { + let _guard = guard; + f() + }) + .await + .map_err(|e| crate::error::Error::PlatformFailure(Box::new(e)))? +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::sync::oneshot; + + struct DropNotification(Option>); + + impl Drop for DropNotification { + fn drop(&mut self) { + let _ = self.0.take().unwrap().send(()); + } + } + + #[tokio::test] + async fn cancelled_operation_retains_guard_until_blocking_work_finishes() { + let (dropped, mut drop_observed) = oneshot::channel(); + let guard = Arc::new(DropNotification(Some(dropped))); + let (started, start_observed) = oneshot::channel(); + let (release, wait_for_release) = std::sync::mpsc::channel(); + let operation = tokio::spawn(with_operation_guard( + guard, + spawn_blocking(move || { + started.send(()).unwrap(); + wait_for_release.recv().unwrap(); + Ok(()) + }), + )); + start_observed.await.unwrap(); + operation.abort(); + assert!(operation.await.unwrap_err().is_cancelled()); + assert!(matches!( + drop_observed.try_recv(), + Err(oneshot::error::TryRecvError::Empty) + )); + release.send(()).unwrap(); + drop_observed.await.unwrap(); + } } diff --git a/crates/uv-keyring/src/lib.rs b/crates/uv-keyring/src/lib.rs index 4b633f7fc93..88e469641b7 100644 --- a/crates/uv-keyring/src/lib.rs +++ b/crates/uv-keyring/src/lib.rs @@ -119,11 +119,8 @@ pub use credential::Credential; use credential::CredentialBuilder; pub use error::{Error, Result}; -#[cfg(any( - all(target_os = "macos", feature = "apple-native"), - all(target_os = "windows", feature = "windows-native"), -))] mod blocking; +pub use blocking::with_operation_guard; #[cfg(test)] mod mock; @@ -152,7 +149,7 @@ mod macos; // #[cfg(all(target_os = "windows", feature = "windows-native"))] #[cfg_attr(docsrs, doc(cfg(target_os = "windows")))] -mod windows; +pub mod windows; mod credential; mod error; @@ -220,8 +217,8 @@ impl Entry { } /// Create an entry from a credential that may be in any credential store. - #[cfg(test)] - fn new_with_credential(credential: Box) -> Self { + #[cfg(any(test, all(target_os = "windows", feature = "windows-native")))] + pub fn new_with_credential(credential: Box) -> Self { Self { inner: credential } } diff --git a/crates/uv-keyring/src/windows.rs b/crates/uv-keyring/src/windows.rs index 664f4d35957..444f30792f9 100644 --- a/crates/uv-keyring/src/windows.rs +++ b/crates/uv-keyring/src/windows.rs @@ -43,6 +43,7 @@ use crate::error::{Error as ErrorCode, Result}; use byteorder::{ByteOrder, LittleEndian}; use std::collections::HashMap; use std::iter::once; +use std::ptr::NonNull; use std::str; use windows::Win32::Foundation::{ ERROR_BAD_USERNAME, ERROR_INVALID_FLAGS, ERROR_INVALID_PARAMETER, ERROR_NO_SUCH_LOGON_SESSION, @@ -51,7 +52,8 @@ use windows::Win32::Foundation::{ use windows::Win32::Security::Credentials::{ CRED_FLAGS, CRED_MAX_CREDENTIAL_BLOB_SIZE, CRED_MAX_GENERIC_TARGET_NAME_LENGTH, CRED_MAX_STRING_LENGTH, CRED_MAX_USERNAME_LENGTH, CRED_PERSIST_ENTERPRISE, CRED_TYPE_GENERIC, - CREDENTIAL_ATTRIBUTEW, CREDENTIALW, CredDeleteW, CredFree, CredReadW, CredWriteW, + CREDENTIAL_ATTRIBUTEW, CREDENTIALW, CredDeleteW, CredEnumerateW, CredFree, CredReadW, + CredWriteW, }; use windows::core::PWSTR; use zeroize::Zeroize; @@ -60,13 +62,99 @@ use zeroize::Zeroize; /// /// See the module header for the meanings of these fields. #[derive(Debug, Clone, PartialEq, Eq)] -struct WinCredential { +pub struct WinCredential { username: String, target_name: String, target_alias: String, comment: String, } +/// A credential returned by [`WinCredential::enumerate`]. +/// +/// The copied secret is zeroized when this value is dropped. +pub struct EnumeratedCredential { + credential: WinCredential, + secret: Vec, +} + +impl EnumeratedCredential { + /// Return the credential metadata. + pub fn credential(&self) -> &WinCredential { + &self.credential + } + + /// Return the credential secret. + /// + /// The returned bytes remain valid until this value is dropped, at which point the backing + /// buffer is zeroized. + pub fn secret(&self) -> &[u8] { + &self.secret + } +} + +impl std::fmt::Debug for EnumeratedCredential { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EnumeratedCredential") + .field("credential", &self.credential) + .field("secret", &"[REDACTED]") + .finish() + } +} + +impl Drop for EnumeratedCredential { + fn drop(&mut self) { + self.secret.zeroize(); + } +} + +/// The allocation returned by [`CredEnumerateW`]. +/// +/// The allocation contains both the array of pointers and the credentials they reference. All +/// credential blobs are zeroized before the allocation is released. +struct EnumeratedCredentialBuffer { + credentials: NonNull<*mut CREDENTIALW>, + count: usize, +} + +impl EnumeratedCredentialBuffer { + /// Take ownership of a successful [`CredEnumerateW`] result. + /// + /// # Safety + /// + /// `credentials` must point to the allocation returned by [`CredEnumerateW`] and contain + /// `count` credential pointers. + unsafe fn from_raw(credentials: *mut *mut CREDENTIALW, count: usize) -> Option { + Some(Self { + credentials: NonNull::new(credentials)?, + count, + }) + } + + /// Return the credential pointers in the allocation. + fn credentials(&self) -> &[*mut CREDENTIALW] { + // SAFETY: `self.credentials` points to the allocation returned by `CredEnumerateW`, which + // contains `self.count` credential pointers and remains live for the lifetime of `self`. + unsafe { std::slice::from_raw_parts(self.credentials.as_ptr(), self.count) } + } +} + +impl Drop for EnumeratedCredentialBuffer { + fn drop(&mut self) { + for credential in self.credentials() { + // SAFETY: Every non-null pointer in the array belongs to the `CredEnumerateW` + // allocation and remains valid until the allocation is released below. + if let Some(credential) = unsafe { credential.as_mut() } { + erase_secret(credential); + } + } + + // SAFETY: `self.credentials` is the allocation returned by `CredEnumerateW`, and this + // guard owns it. + unsafe { CredFree(self.credentials.as_ptr().cast()) }; + } +} + // Windows API type mappings: // DWORD is u32 // LPCWSTR is *const u16 @@ -186,6 +274,102 @@ impl CredentialApi for WinCredential { } impl WinCredential { + /// Return all Windows Generic credentials with target names matching `target_prefix`. + /// + /// Windows credential enumeration accepts a single trailing wildcard, so this method treats + /// the provided value as a literal target-name prefix. Each returned value includes the secret + /// from the enumeration result, avoiding a separate lookup for every credential. + pub async fn enumerate(target_prefix: &str) -> Result> { + if target_prefix.contains('\0') { + return Err(ErrorCode::Invalid( + "target prefix".to_string(), + "cannot contain a null character".to_string(), + )); + } + if target_prefix.contains('*') { + return Err(ErrorCode::Invalid( + "target prefix".to_string(), + "cannot contain a wildcard".to_string(), + )); + } + + let mut filter = to_wstr(&format!("{target_prefix}*")); + crate::blocking::spawn_blocking(move || { + let mut count = 0; + let mut credentials: *mut *mut CREDENTIALW = std::ptr::null_mut(); + + // SAFETY: `filter` is null-terminated. On success, Windows initializes `count` and + // `credentials`, and the returned allocation remains valid until released by + // `CredFree`. + match unsafe { + CredEnumerateW( + PWSTR(filter.as_mut_ptr()), + None, + &raw mut count, + &raw mut credentials, + ) + } { + Ok(()) => {} + Err(err) if err == ERROR_NOT_FOUND.into() => return Ok(Vec::new()), + Err(err) => return Err(Error(err).into()), + } + + if count == 0 { + if let Some(credentials) = NonNull::new(credentials) { + // SAFETY: `credentials` is the allocation returned by `CredEnumerateW`. + unsafe { CredFree(credentials.as_ptr().cast()) }; + } + return Ok(Vec::new()); + } + + // SAFETY: `CredEnumerateW` succeeded and returned `count` credential pointers. + let Some(credentials) = + (unsafe { EnumeratedCredentialBuffer::from_raw(credentials, count as usize) }) + else { + return Err(ErrorCode::PlatformFailure(Box::new(std::io::Error::other( + "CredEnumerateW returned a null credential array", + )))); + }; + + let mut enumerated = Vec::with_capacity(credentials.count); + for credential in credentials.credentials() { + // SAFETY: Every pointer in the array belongs to the `CredEnumerateW` allocation + // and remains valid while `credentials` is live. + let Some(credential) = (unsafe { credential.as_ref() }) else { + return Err(ErrorCode::PlatformFailure(Box::new(std::io::Error::other( + "CredEnumerateW returned a null credential pointer", + )))); + }; + if credential.Type != CRED_TYPE_GENERIC { + continue; + } + enumerated.push(EnumeratedCredential { + credential: Self::extract_credential(credential)?, + secret: extract_secret(credential)?, + }); + } + Ok(enumerated) + }) + .await + } + + /// Create a credential with a custom target and description. + pub fn with_target(target: String, comment: String) -> Result { + let credential = Self { + username: String::new(), + target_name: target, + target_alias: String::new(), + comment, + }; + credential.validate_attributes(None, None)?; + Ok(credential) + } + + /// Return the target identifying this credential. + pub fn target_name(&self) -> &str { + &self.target_name + } + fn validate_attributes(&self, secret: Option<&[u8]>, password: Option<&str>) -> Result<()> { if self.username.len() > CRED_MAX_USERNAME_LENGTH as usize { return Err(ErrorCode::TooLong( @@ -429,24 +613,33 @@ fn extract_password(credential: &CREDENTIALW) -> Result { result } -#[expect(clippy::unnecessary_wraps)] fn extract_secret(credential: &CREDENTIALW) -> Result> { - let blob_pointer: *const u8 = credential.CredentialBlob; let blob_len: usize = credential.CredentialBlobSize as usize; if blob_len == 0 { return Ok(Vec::new()); } - let blob = unsafe { std::slice::from_raw_parts(blob_pointer, blob_len) }; + let Some(blob_pointer) = NonNull::new(credential.CredentialBlob) else { + return Err(ErrorCode::PlatformFailure(Box::new(std::io::Error::other( + "Windows returned a null pointer for a non-empty credential blob", + )))); + }; + // SAFETY: Windows guarantees that a non-empty credential blob points to + // `CredentialBlobSize` readable bytes for the lifetime of the credential. + let blob = unsafe { std::slice::from_raw_parts(blob_pointer.as_ptr(), blob_len) }; Ok(blob.to_vec()) } fn erase_secret(credential: &mut CREDENTIALW) { - let blob_pointer: *mut u8 = credential.CredentialBlob; let blob_len: usize = credential.CredentialBlobSize as usize; if blob_len == 0 { return; } - let blob = unsafe { std::slice::from_raw_parts_mut(blob_pointer, blob_len) }; + let Some(blob_pointer) = NonNull::new(credential.CredentialBlob) else { + return; + }; + // SAFETY: The caller owns the native credential allocation, and Windows guarantees that a + // non-empty credential blob points to `CredentialBlobSize` writable bytes in that allocation. + let blob = unsafe { std::slice::from_raw_parts_mut(blob_pointer.as_ptr(), blob_len) }; blob.zeroize(); } @@ -748,6 +941,76 @@ mod tests { ); } + #[tokio::test] + async fn test_enumerate_target_prefix() -> Result<()> { + let name = generate_random_string(); + let prefix = format!("uv-keyring-enumerate-{name}-"); + let targets = [ + format!("{prefix}first"), + format!("{prefix}second"), + format!("uv-keyring-other-{name}"), + ]; + + let result = async { + for (index, target) in targets.iter().enumerate() { + let credential = + WinCredential::new_with_target(Some(target), "enumeration-test", "test-user")?; + Entry::new_with_credential(Box::new(credential)) + .set_secret(format!("secret-{index}").as_bytes()) + .await?; + } + + WinCredential::enumerate(&prefix.to_uppercase()).await + } + .await; + + for target in &targets { + if let Ok(credential) = + WinCredential::new_with_target(Some(target), "enumeration-test", "test-user") + { + let _ = Entry::new_with_credential(Box::new(credential)) + .delete_credential() + .await; + } + } + + let enumerated = result?; + let mut enumerated_credentials = enumerated + .iter() + .map(|credential| { + ( + credential.credential().target_name.clone(), + credential.secret().to_vec(), + ) + }) + .collect::>(); + enumerated_credentials.sort(); + + let mut expected_credentials = targets[..2] + .iter() + .enumerate() + .map(|(index, target)| (target.clone(), format!("secret-{index}").into_bytes())) + .collect::>(); + expected_credentials.sort(); + assert_eq!(enumerated_credentials, expected_credentials); + assert!( + WinCredential::enumerate(&format!("uv-keyring-missing-{name}")) + .await? + .is_empty() + ); + Ok(()) + } + + #[tokio::test] + async fn test_enumerate_rejects_non_literal_prefix() { + for prefix in ["uv-keyring-*", "uv-keyring-\0"] { + assert!(matches!( + WinCredential::enumerate(prefix).await, + Err(ErrorCode::Invalid(..)) + )); + } + } + #[tokio::test] async fn test_get_credential() { let name = generate_random_string(); diff --git a/crates/uv-publish-commands/src/lib.rs b/crates/uv-publish-commands/src/lib.rs index b1dc26c3f1f..0045569690d 100644 --- a/crates/uv-publish-commands/src/lib.rs +++ b/crates/uv-publish-commands/src/lib.rs @@ -411,7 +411,7 @@ async fn gather_credentials( debug!("Fetching password from keyring"); if let Some(keyring_password) = provider .fetch(DisplaySafeUrl::ref_cast(&publish_url), Some(username)) - .await + .await? .as_ref() .and_then(|credentials| credentials.password()) { diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index 27eedaaf95e..794f5ae84e3 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -114,6 +114,7 @@ embed-manifest = { workspace = true } [dev-dependencies] uv-python-managed = { workspace = true } +uv-keyring = { workspace = true } uv-cache-key = { workspace = true } uv-extract = { workspace = true } uv-platform = { workspace = true } @@ -153,7 +154,7 @@ uv-unix = { workspace = true } [features] default = ["performance", "test-defaults"] -native-auth = [] +native-auth = ["uv-auth/native-auth"] # Use better memory allocators, etc. performance = ["performance-memory-allocator"] performance-memory-allocator = ["dep:uv-performance-memory-allocator"] diff --git a/crates/uv/src/commands/auth/helper.rs b/crates/uv/src/commands/auth/helper.rs index 07eb2269dbe..cf70c0451c9 100644 --- a/crates/uv/src/commands/auth/helper.rs +++ b/crates/uv/src/commands/auth/helper.rs @@ -73,7 +73,7 @@ async fn credentials_for_url( let backend = AuthBackend::from_settings(preview).await?; let credentials = match &backend { - AuthBackend::System(provider) => provider.fetch(url, username).await, + AuthBackend::System(provider) => provider.fetch(url, username).await?, AuthBackend::TextStore(store, _lock) => store.get_credentials(url, username)?.cloned(), }; Ok(credentials) diff --git a/crates/uv/src/commands/auth/logout.rs b/crates/uv/src/commands/auth/logout.rs index 9986a62c4e0..6a7410e326c 100644 --- a/crates/uv/src/commands/auth/logout.rs +++ b/crates/uv/src/commands/auth/logout.rs @@ -11,7 +11,7 @@ use uv_preview::Preview; /// Logout from a service. /// -/// If no username is provided, defaults to `__token__`. +/// Native authentication infers a unique matching username; plaintext defaults to `__token__`. pub(crate) async fn logout( service: Service, username: Option, @@ -39,7 +39,14 @@ pub(crate) async fn logout( } (Some(cli), None) => cli, (None, Some(url)) => url.to_string(), - (None, None) => "__token__".to_string(), + (None, None) => match &backend { + AuthBackend::System(provider) => provider + .fetch(&url, None) + .await? + .and_then(|credentials| credentials.username().map(str::to_string)) + .unwrap_or_else(|| "__token__".to_string()), + AuthBackend::TextStore(..) => "__token__".to_string(), + }, }; if username.is_empty() { bail!("Username cannot be empty"); diff --git a/crates/uv/src/commands/auth/token.rs b/crates/uv/src/commands/auth/token.rs index 4456b1ded9e..b2ef7a14dd1 100644 --- a/crates/uv/src/commands/auth/token.rs +++ b/crates/uv/src/commands/auth/token.rs @@ -25,27 +25,35 @@ pub(crate) async fn token( "Cannot specify a username both via the URL and CLI; found `--username {cli}` and `{url}`" ); } - (Some(cli), None) => cli, - (None, Some(url)) => url.to_string(), - (None, None) => "__token__".to_string(), + (Some(cli), None) => Some(cli), + (None, Some(url)) => Some(url.to_string()), + (None, None) if matches!(&backend, AuthBackend::System(_)) => None, + (None, None) => Some("__token__".to_string()), }; - if username.is_empty() { + if username.as_deref().is_some_and(str::is_empty) { bail!("Username cannot be empty"); } - let display_url = if username == "__token__" { - url.without_credentials().to_string() - } else { - format!("{username}@{}", url.without_credentials()) + let display_url = match username.as_deref() { + Some(username) if username != "__token__" => { + format!("{username}@{}", url.without_credentials()) + } + _ => url.without_credentials().to_string(), }; let credentials = match &backend { - AuthBackend::System(provider) => provider - .fetch(url, Some(&username)) - .await - .ok_or_else(|| anyhow::anyhow!("Failed to fetch credentials for `{display_url}`"))?, + AuthBackend::System(provider) => { + let credentials = provider.fetch(url, username.as_deref()).await?; + let credentials = if credentials.is_none() && username.is_none() { + provider.fetch(url, Some("__token__")).await? + } else { + credentials + }; + credentials + .ok_or_else(|| anyhow::anyhow!("Failed to fetch credentials for `{display_url}`"))? + } AuthBackend::TextStore(store, _lock) => store - .get_credentials(url, Some(&username))? + .get_credentials(url, username.as_deref())? .cloned() .ok_or_else(|| anyhow::anyhow!("Failed to fetch credentials for `{display_url}`"))?, }; @@ -53,7 +61,7 @@ pub(crate) async fn token( let Some(password) = credentials.password() else { bail!( "No {} found for `{display_url}`", - if username != "__token__" { + if credentials.username() != Some("__token__") { "password" } else { "token" diff --git a/crates/uv/tests/it/auth.rs b/crates/uv/tests/it/auth.rs index 73ff996656d..4f1c05b9312 100644 --- a/crates/uv/tests/it/auth.rs +++ b/crates/uv/tests/it/auth.rs @@ -6,6 +6,47 @@ use uv_static::EnvVars; use uv_test::uv_snapshot; +#[cfg(feature = "native-auth")] +struct NativeCredentialCleanup<'a> { + context: &'a uv_test::TestContext, + entries: Vec<(String, String)>, +} + +#[cfg(feature = "native-auth")] +impl<'a> NativeCredentialCleanup<'a> { + fn new(context: &'a uv_test::TestContext, entries: &[(&str, &str)]) -> Self { + let cleanup = Self { + context, + entries: entries + .iter() + .map(|(service, username)| ((*service).to_string(), (*username).to_string())) + .collect(), + }; + cleanup.remove(); + cleanup + } + + fn remove(&self) { + for (service, username) in &self.entries { + let _ = self + .context + .auth_logout() + .arg(service) + .arg("--username") + .arg(username) + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth") + .output(); + } + } +} + +#[cfg(feature = "native-auth")] +impl Drop for NativeCredentialCleanup<'_> { + fn drop(&mut self) { + self.remove(); + } +} + #[tokio::test] async fn invalid_cloud_endpoint_urls() { let context = uv_test::test_context!("3.12").with_filtered_http_retries(); @@ -234,6 +275,234 @@ async fn add_package_native_auth() -> Result<()> { Ok(()) } +#[tokio::test] +#[cfg(all( + feature = "native-auth", + feature = "test-python", + feature = "test-pypi" +))] +async fn native_auth_uses_path_specific_credentials_in_one_client() -> Result<()> { + let context = uv_test::test_context!("3.12").with_real_home(); + let proxy = crate::pypi_proxy::start().await; + let heron_index = proxy.url("/basic-auth-heron/simple"); + let eagle_index = proxy.url("/basic-auth-eagle/simple"); + let _cleanup = NativeCredentialCleanup::new( + &context, + &[ + (heron_index.as_str(), "public"), + (eagle_index.as_str(), "public"), + ], + ); + + context + .auth_login() + .arg(&heron_index) + .arg("--username") + .arg("public") + .arg("--password") + .arg("heron") + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth") + .assert() + .success(); + context + .auth_login() + .arg(&eagle_index) + .arg("--username") + .arg("public") + .arg("--password") + .arg("eagle") + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth") + .assert() + .success(); + + context + .temp_dir + .child("pyproject.toml") + .write_str(&format!( + r#" + [project] + name = "path-specific-auth" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio", "iniconfig"] + + [tool.uv.sources] + anyio = {{ index = "heron" }} + iniconfig = {{ index = "eagle" }} + + [[tool.uv.index]] + name = "heron" + url = "{}" + explicit = true + + [[tool.uv.index]] + name = "eagle" + url = "{}" + explicit = true + "#, + proxy.username_url("public", "/basic-auth-heron/simple"), + proxy.username_url("public", "/basic-auth-eagle/simple"), + ))?; + + let mut filters = context.filters(); + filters.push((r"(?m)^WARN Range requests not supported[^\n]*\n", "")); + uv_snapshot!(filters, context.lock() + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" + exit_code: 0 (success) + ----- stderr ----- + Resolved 5 packages in [TIME] + "); + + Ok(()) +} + +#[tokio::test] +#[cfg(feature = "native-auth")] +async fn native_auth_installs_without_username_in_index_url() -> Result<()> { + let context = uv_test::test_context!("3.12").with_real_home(); + let proxy = crate::pypi_proxy::start().await; + let index = proxy.url("/basic-auth/simple"); + let _cleanup = NativeCredentialCleanup::new(&context, &[(index.as_str(), "public")]); + + context + .auth_login() + .arg(&index) + .arg("--username") + .arg("public") + .arg("--password") + .arg("heron") + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--dry-run") + .arg("--no-deps") + .arg("--default-index") + .arg(&index) + .arg("iniconfig") + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" + exit_code: 0 (success) + ----- stderr ----- + Resolved 1 package in [TIME] + Would download 1 package + Would install 1 package + + iniconfig==2.0.0 + "); + + Ok(()) +} + +#[tokio::test] +#[cfg(feature = "native-auth")] +async fn native_auth_ignores_plaintext_credentials() -> Result<()> { + let context = uv_test::test_context!("3.12").with_real_home(); + let proxy = crate::pypi_proxy::start().await; + let service = proxy.url("/basic-auth/simple"); + let credentials_directory = context.temp_dir.child("credentials"); + let _cleanup = NativeCredentialCleanup::new(&context, &[(service.as_str(), "public")]); + + context + .auth_login() + .arg(&service) + .arg("--username") + .arg("public") + .arg("--password") + .arg("outdated-password") + .env( + EnvVars::UV_CREDENTIALS_DIR, + credentials_directory.as_os_str(), + ) + .assert() + .success(); + + uv_snapshot!(context.filters(), context.auth_login() + .arg(&service) + .arg("--username") + .arg("public") + .arg("--password") + .arg("heron") + .env(EnvVars::UV_CREDENTIALS_DIR, credentials_directory.as_os_str()) + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" + exit_code: 0 (success) + ----- stderr ----- + warning: Ignoring plaintext credentials at [TEMP_DIR]/credentials/credentials.toml because native authentication is enabled + Stored credentials for public@http://[LOCALHOST]/basic-auth + "); + + uv_snapshot!(context.filters(), context.auth_token() + .arg(&service) + .arg("--username") + .arg("public") + .env(EnvVars::UV_CREDENTIALS_DIR, credentials_directory.as_os_str()) + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" + exit_code: 0 (success) + ----- stdout ----- + heron + + ----- stderr ----- + warning: Ignoring plaintext credentials at [TEMP_DIR]/credentials/credentials.toml because native authentication is enabled + "); + + context + .pip_install() + .arg("--dry-run") + .arg("--no-deps") + .arg("--default-index") + .arg(proxy.username_url("public", "/basic-auth/simple")) + .arg("iniconfig") + .env( + EnvVars::UV_CREDENTIALS_DIR, + credentials_directory.as_os_str(), + ) + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth") + .assert() + .success(); + + Ok(()) +} + +#[tokio::test] +#[cfg(feature = "native-auth")] +async fn native_auth_reports_ambiguous_package_credentials() -> Result<()> { + let context = uv_test::test_context!("3.12").with_real_home(); + let proxy = crate::pypi_proxy::start().await; + let service = proxy.url("/basic-auth/simple"); + let _cleanup = NativeCredentialCleanup::new( + &context, + &[(service.as_str(), "public"), (service.as_str(), "other")], + ); + + for (username, password) in [("public", "heron"), ("other", "eagle")] { + context + .auth_login() + .arg(&service) + .arg("--username") + .arg(username) + .arg("--password") + .arg(password) + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth") + .assert() + .success(); + } + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--dry-run") + .arg("--no-deps") + .arg("--default-index") + .arg(&service) + .arg("iniconfig") + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" + exit_code: 2 (failure) + ----- stderr ----- + error: Failed to fetch: http://[LOCALHOST]/basic-auth/simple/iniconfig/ + cause: Failed to fetch credentials from the native credential store + cause: Multiple credentials found for URL 'http://[LOCALHOST]/basic-auth/simple/iniconfig/', specify which username to use + "); + + Ok(()) +} + #[tokio::test] #[cfg(feature = "native-auth")] async fn token_native_auth() -> Result<()> { @@ -294,14 +563,13 @@ async fn token_native_auth() -> Result<()> { heron "); - // Without the username - // TODO(zanieb): Add a hint here if we can? + // Infer the only matching username. uv_snapshot!(context.filters(), context.auth_token() .arg(proxy.url("/basic-auth/simple")) .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" - exit_code: 2 (failure) - ----- stderr ----- - error: Failed to fetch credentials for `http://[LOCALHOST]/basic-auth/simple` + exit_code: 0 (success) + ----- stdout ----- + heron "); // With a mismatched username @@ -328,13 +596,13 @@ async fn token_native_auth() -> Result<()> { " ); - // Retrieve the token without a username + // A named user and a token account are ambiguous without an explicit username. uv_snapshot!(context.filters(), context.auth_token() .arg(proxy.url("/basic-auth/simple")) .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" - exit_code: 0 (success) - ----- stdout ----- - heron + exit_code: 2 (failure) + ----- stderr ----- + error: Multiple credentials found for URL 'http://[LOCALHOST]/basic-auth/simple', specify which username to use "); context @@ -450,22 +718,22 @@ async fn token_native_auth_realm() -> Result<()> { heron "); - // Without the username (defaults to __token__ which wasn't stored) + // Infer the only username stored for the realm. uv_snapshot!(context.filters(), context.auth_token() .arg(proxy.uri()) .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" - exit_code: 2 (failure) - ----- stderr ----- - error: Failed to fetch credentials for `http://[LOCALHOST]/` + exit_code: 0 (success) + ----- stdout ----- + heron "); - // Without the username (defaults to __token__ which wasn't stored) + // Infer the same username for a matching child URL. uv_snapshot!(context.filters(), context.auth_token() .arg(proxy.url("/basic-auth/simple")) .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" - exit_code: 2 (failure) - ----- stderr ----- - error: Failed to fetch credentials for `http://[LOCALHOST]/basic-auth/simple` + exit_code: 0 (success) + ----- stdout ----- + heron "); // With a mismatched username @@ -503,13 +771,13 @@ async fn token_native_auth_realm() -> Result<()> { " ); - // Retrieve the token without a username + // A named user and a token account are ambiguous without an explicit username. uv_snapshot!(context.filters(), context.auth_token() .arg(proxy.uri()) .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" - exit_code: 0 (success) - ----- stdout ----- - heron + exit_code: 2 (failure) + ----- stderr ----- + error: Multiple credentials found for URL 'http://[LOCALHOST]/', specify which username to use "); context @@ -693,17 +961,26 @@ async fn logout_native_auth() -> Result<()> { " ); - // Logout without a username - // TODO(zanieb): Add a hint here if we can? + // Infer the only matching username during logout. uv_snapshot!(context.filters(), context.auth_logout() .arg(proxy.url("/basic-auth/simple")) .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" - exit_code: 2 (failure) + exit_code: 0 (success) ----- stderr ----- - error: Unable to remove credentials for `http://[LOCALHOST]/basic-auth` - cause: No matching entry found in secure storage + Removed credentials for public@http://[LOCALHOST]/basic-auth "); + context + .auth_login() + .arg(proxy.url("/basic-auth/simple")) + .arg("--username") + .arg("public") + .arg("--password") + .arg("heron") + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth") + .assert() + .success(); + // Logout with a username uv_snapshot!(context.filters(), context.auth_logout() .arg(proxy.url("/basic-auth/simple")) @@ -802,6 +1079,13 @@ async fn logout_token_native_auth() -> Result<()> { #[cfg(feature = "native-auth")] fn login_native_auth_url() { let context = uv_test::test_context_with_versions!(&[]).with_real_home(); + let _cleanup = NativeCredentialCleanup::new( + &context, + &[ + ("https://example.com", "test"), + ("http://localhost:1324", "test"), + ], + ); // A domain-only service name gets https:// prepended uv_snapshot!(context.auth_login() @@ -1642,38 +1926,72 @@ fn logout_text_store_multiple_usernames() { #[test] #[cfg(feature = "native-auth")] -fn native_auth_prefix_match() -> Result<()> { +fn native_auth_lock_directory_ignores_credentials_override() { let context = uv_test::test_context_with_versions!(&[]).with_real_home(); + let service = "native-lock-directory.example.com"; + let username = "native-lock-user"; + let _cleanup = NativeCredentialCleanup::new(&context, &[(service, username)]); - // Clear state before the test context - .auth_logout() - .arg("https://example.com/api") + .auth_login() + .arg(service) .arg("--username") - .arg("testuser") + .arg(username) + .arg("--password") + .arg("lock-password") .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth") - .status()?; + .env(EnvVars::UV_CREDENTIALS_DIR, context.temp_dir.as_os_str()) + .assert() + .success(); + + assert!(!context.temp_dir.child("native").exists()); +} + +#[tokio::test] +#[cfg(feature = "native-auth")] +async fn native_auth_infers_legacy_token_account() -> Result<()> { + let context = uv_test::test_context_with_versions!(&[]).with_real_home(); + let service = "native-legacy-token-inference.example.com"; + let _cleanup = NativeCredentialCleanup::new(&context, &[(service, "__token__")]); + uv_keyring::Entry::new(&format!("uv:{service}"), "__token__")? + .set_password("legacy-token") + .await?; + uv_snapshot!(context.auth_token() + .arg(service) + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @" + exit_code: 0 (success) + ----- stdout ----- + legacy-token + "); + Ok(()) +} + +#[test] +#[cfg(feature = "native-auth")] +fn native_auth_prefix_match() { + let context = uv_test::test_context_with_versions!(&[]).with_real_home(); + let service = "https://native-prefix.example.com/api"; + let host = "native-prefix.example.com"; + let username = "native-prefix-user"; + let _cleanup = NativeCredentialCleanup::new(&context, &[(service, username), (host, username)]); - // Login with credentials for a path uv_snapshot!(context.auth_login() - .arg("https://example.com/api") + .arg(service) .arg("--username") - .arg("testuser") + .arg(username) .arg("--password") .arg("testpass") .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" exit_code: 0 (success) ----- stderr ----- - Stored credentials for testuser@https://example.com/api + Stored credentials for native-prefix-user@https://native-prefix.example.com/api " ); - // A request for a child path does not match, the native store does not yet implement prefix - // matching uv_snapshot!(context.auth_token() - .arg("https://example.com/api/v1") + .arg("https://native-prefix.example.com/api/v1") .arg("--username") - .arg("testuser") + .arg(username) .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" exit_code: 0 (success) ----- stdout ----- @@ -1681,62 +1999,306 @@ fn native_auth_prefix_match() -> Result<()> { " ); - Ok(()) + uv_snapshot!(context.auth_token() + .arg("https://native-prefix.example.com/apiv1") + .arg("--username") + .arg(username) + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" + exit_code: 2 (failure) + ----- stderr ----- + error: Failed to fetch credentials for `native-prefix-user@https://native-prefix.example.com/apiv1` + " + ); } #[test] #[cfg(feature = "native-auth")] -fn native_auth_host_fallback() -> Result<()> { +fn native_auth_host_fallback() { let context = uv_test::test_context_with_versions!(&[]).with_real_home(); + let service = "native-host.example.com"; + let username = "native-host-user"; + let _cleanup = NativeCredentialCleanup::new(&context, &[(service, username)]); + + uv_snapshot!(context.auth_login() + .arg(service) + .arg("--username") + .arg(username) + .arg("--password") + .arg("hostpass") + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" + exit_code: 0 (success) + ----- stderr ----- + Stored credentials for native-host-user@https://native-host.example.com/ + " + ); + + uv_snapshot!(context.auth_token() + .arg("https://native-host.example.com/any/path") + .arg("--username") + .arg(username) + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" + exit_code: 0 (success) + ----- stdout ----- + hostpass + " + ); + + uv_snapshot!(context.auth_token() + .arg("https://native-other.example.com/any/path") + .arg("--username") + .arg(username) + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" + exit_code: 2 (failure) + ----- stderr ----- + error: Failed to fetch credentials for `native-host-user@https://native-other.example.com/any/path` + " + ); +} + +#[test] +#[cfg(feature = "native-auth")] +fn native_auth_multiple_users() { + let context = uv_test::test_context_with_versions!(&[]).with_real_home(); + let service = "native-users.example.com"; + let _cleanup = + NativeCredentialCleanup::new(&context, &[(service, "user1"), (service, "user2")]); - // Clear state before the test context - .auth_logout() - .arg("example.com") + .auth_login() + .arg(service) .arg("--username") - .arg("testuser") + .arg("user1") + .arg("--password") + .arg("pass1") .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth") - .status()?; + .assert() + .success(); - // Login with credentials for the host - uv_snapshot!(context.auth_login() - .arg("example.com") + context + .auth_login() + .arg(service) .arg("--username") - .arg("testuser") + .arg("user2") .arg("--password") - .arg("hostpass") + .arg("pass2") + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth") + .assert() + .success(); + + uv_snapshot!(context.auth_token() + .arg(service) + .arg("--username") + .arg("user1") + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" + exit_code: 0 (success) + ----- stdout ----- + pass1 + " + ); + + uv_snapshot!(context.auth_token() + .arg(service) + .arg("--username") + .arg("user2") .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" exit_code: 0 (success) + ----- stdout ----- + pass2 + " + ); + + uv_snapshot!(context.auth_token() + .arg(service) + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" + exit_code: 2 (failure) ----- stderr ----- - Stored credentials for testuser@https://example.com/ + error: Multiple credentials found for URL 'https://native-users.example.com/', specify which username to use + " + ); + + uv_snapshot!(context.auth_logout() + .arg(service) + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" + exit_code: 2 (failure) + ----- stderr ----- + error: Multiple credentials found for URL 'https://native-users.example.com/', specify which username to use + " + ); + + uv_snapshot!(context.filters(), context.auth_helper() + .arg("--protocol=bazel") + .arg("get") + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth,auth-helper"), + input=r#"{"uri":"https://native-users.example.com/path"}"#, + @" + exit_code: 2 (failure) + ----- stderr ----- + error: Multiple credentials found for URL 'https://native-users.example.com/path', specify which username to use + " + ); + + context + .auth_logout() + .arg(service) + .arg("--username") + .arg("user1") + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth") + .assert() + .success(); + + uv_snapshot!(context.auth_token() + .arg(service) + .arg("--username") + .arg("user1") + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" + exit_code: 2 (failure) + ----- stderr ----- + error: Failed to fetch credentials for `user1@https://native-users.example.com/` " ); - // Should fallback to host-level matching uv_snapshot!(context.auth_token() - .arg("https://example.com/any/path") + .arg(service) .arg("--username") - .arg("testuser") + .arg("user2") .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" exit_code: 0 (success) ----- stdout ----- - hostpass + pass2 + " + ); +} + +#[test] +#[cfg(feature = "native-auth")] +fn native_auth_token_account_is_ambiguous() { + let context = uv_test::test_context_with_versions!(&[]).with_real_home(); + let service = "native-token-ambiguity.example.com"; + let _cleanup = + NativeCredentialCleanup::new(&context, &[(service, "alice"), (service, "__token__")]); + + context + .auth_login() + .arg(service) + .arg("--username") + .arg("alice") + .arg("--password") + .arg("alice-password") + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth") + .assert() + .success(); + context + .auth_login() + .arg(service) + .arg("--token") + .arg("token-password") + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth") + .assert() + .success(); + + uv_snapshot!(context.auth_token() + .arg(service) + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" + exit_code: 2 (failure) + ----- stderr ----- + error: Multiple credentials found for URL 'https://native-token-ambiguity.example.com/', specify which username to use + " + ); + + uv_snapshot!(context.auth_logout() + .arg(service) + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" + exit_code: 2 (failure) + ----- stderr ----- + error: Multiple credentials found for URL 'https://native-token-ambiguity.example.com/', specify which username to use " ); - // A request to another host should not work uv_snapshot!(context.auth_token() - .arg("https://another-example.com/any/path") + .arg(service) .arg("--username") - .arg("testuser") + .arg("__token__") + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" + exit_code: 0 (success) + ----- stdout ----- + token-password + " + ); +} + +#[test] +#[cfg(feature = "native-auth")] +fn native_auth_logout_is_service_scoped() { + let context = uv_test::test_context_with_versions!(&[]).with_real_home(); + let first_service = "https://native-scoped.example.com/first"; + let second_service = "https://native-scoped.example.com/second"; + let username = "native-scoped-user"; + let _cleanup = NativeCredentialCleanup::new( + &context, + &[(first_service, username), (second_service, username)], + ); + + context + .auth_login() + .arg(first_service) + .arg("--username") + .arg(username) + .arg("--password") + .arg("pass-first") + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth") + .assert() + .success(); + + context + .auth_login() + .arg(second_service) + .arg("--username") + .arg(username) + .arg("--password") + .arg("pass-second") + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth") + .assert() + .success(); + + context + .auth_logout() + .arg(first_service) + .arg("--username") + .arg(username) + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth") + .assert() + .success(); + + uv_snapshot!(context.auth_token() + .arg(first_service) + .arg("--username") + .arg(username) .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" exit_code: 2 (failure) ----- stderr ----- - error: Failed to fetch credentials for `testuser@https://another-example.com/any/path` + error: Failed to fetch credentials for `native-scoped-user@https://native-scoped.example.com/first` " ); - Ok(()) + uv_snapshot!(context.auth_token() + .arg(second_service) + .arg("--username") + .arg(username) + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth"), @r" + exit_code: 0 (success) + ----- stdout ----- + pass-second + " + ); + + context + .auth_logout() + .arg(second_service) + .arg("--username") + .arg(username) + .env(EnvVars::UV_PREVIEW_FEATURES, "native-auth") + .assert() + .success(); } /// Test credential helper with basic auth credentials diff --git a/docs/concepts/authentication/cli.md b/docs/concepts/authentication/cli.md index e9d94dcd73c..3cae0f543e3 100644 --- a/docs/concepts/authentication/cli.md +++ b/docs/concepts/authentication/cli.md @@ -42,6 +42,9 @@ To remove credentials, use the `uv auth logout` command: $ uv auth logout example.com ``` +When using the system-native storage backend, uv can infer the username if exactly one account +matches the service. If multiple accounts match, specify `--username`. + !!! note The credentials will not be invalidated with the remote server, i.e., they will only be removed @@ -55,12 +58,15 @@ To show the credential stored for a given URL, use the `uv auth token` command: $ uv auth token example.com ``` -If a username was used to log in, it will need to be provided as well, e.g.: +With the default plaintext storage backend, a username used to log in must also be provided, e.g.: ```console $ uv auth token --username foo example.com ``` +When using the system-native storage backend, uv can infer the username if exactly one account +matches the service. If multiple accounts match, specify `--username`. + ## Using credentials with external tools `uv auth helper` allows tools that support credential helpers to request HTTP credentials from uv. @@ -95,4 +101,8 @@ with Bazel. Credentials are persisted to the uv [credentials store](./http.md#the-uv-credentials-store). By default, credentials are written to a plaintext file. An encrypted system-native storage backend -can be enabled with `UV_PREVIEW_FEATURES=native-auth`. +is available in [preview](../preview.md) and can be enabled with `--preview-features native-auth` or +`UV_PREVIEW_FEATURES=native-auth`. + +When native storage is enabled, existing plaintext credentials are not used. uv warns if a plaintext +credentials file exists; run `uv auth login` again to add credentials to the native store. diff --git a/docs/concepts/authentication/http.md b/docs/concepts/authentication/http.md index cb60f120f5f..379c1b7f0e1 100644 --- a/docs/concepts/authentication/http.md +++ b/docs/concepts/authentication/http.md @@ -46,7 +46,9 @@ edited manually. Currently, uv only searches the native store for credentials it has added to the secret store — it will not retrieve credentials persisted by other applications. - Set `UV_PREVIEW_FEATURES=native-auth` to use this storage mechanism. + Set `UV_PREVIEW_FEATURES=native-auth` or pass `--preview-features native-auth` to use this + storage mechanism. When enabled, uv does not read existing plaintext credentials and warns if a + plaintext credentials file exists. ## Keyring providers diff --git a/hawk.toml b/hawk.toml index cc3dabe88a9..43b5a46aca3 100644 --- a/hawk.toml +++ b/hawk.toml @@ -352,3 +352,21 @@ reason = "retained as a public pyvenv.cfg editing helper" crate = "uv_extract" module = "dirhash" reason = "intentionally public" + +[[override]] +lint = "hawk::unnecessary_restricted_visibility" +crate = "uv_keyring" +item = "blocking::spawn_blocking" +kind = "function" +level = "expect" +target = 'cfg(not(any(target_os = "macos", target_os = "windows")))' +reason = "called by the macOS and Windows keyring backends" + +[[override]] +lint = "hawk::unnecessary_public" +crate = "uv_keyring" +item = "Entry::new_with_credential" +kind = "inherent_method" +level = "expect" +target = "cfg(not(windows))" +reason = "used across crates by uv-auth's Windows native credential store"