diff --git a/crates/uv-auth/src/lib.rs b/crates/uv-auth/src/lib.rs index 26ea60b2d..962c6fcc9 100644 --- a/crates/uv-auth/src/lib.rs +++ b/crates/uv-auth/src/lib.rs @@ -3,7 +3,7 @@ use std::sync::{Arc, LazyLock}; use tracing::trace; use cache::CredentialsCache; -pub use credentials::Credentials; +pub use credentials::{Credentials, Username}; pub use index::{AuthPolicy, Index, Indexes}; pub use keyring::KeyringProvider; pub use middleware::AuthMiddleware; diff --git a/crates/uv-auth/src/store.rs b/crates/uv-auth/src/store.rs index 43d89363a..a761bbaf1 100644 --- a/crates/uv-auth/src/store.rs +++ b/crates/uv-auth/src/store.rs @@ -210,7 +210,7 @@ struct TomlCredentials { /// A credential store with a plain text storage backend. #[derive(Debug, Default)] pub struct TextCredentialStore { - credentials: FxHashMap, + credentials: FxHashMap<(Service, Username), Credentials>, } impl TextCredentialStore { @@ -246,10 +246,19 @@ impl TextCredentialStore { let content = fs::read_to_string(path)?; let credentials: TomlCredentials = toml::from_str(&content)?; - let credentials: FxHashMap = credentials + let credentials: FxHashMap<(Service, Username), Credentials> = credentials .credentials .into_iter() - .map(|credential| (credential.service.clone(), credential.credentials)) + .map(|credential| { + let username = match &credential.credentials { + Credentials::Basic { username, .. } => username.clone(), + Credentials::Bearer { .. } => Username::none(), + }; + ( + (credential.service.clone(), username), + credential.credentials, + ) + }) .collect(); Ok(Self { credentials }) @@ -278,7 +287,7 @@ impl TextCredentialStore { let credentials = self .credentials .into_iter() - .map(|(service, credentials)| TomlCredential { + .map(|((service, _username), credentials)| TomlCredential { service, credentials, }) @@ -307,18 +316,18 @@ impl TextCredentialStore { // 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(DisplaySafeUrl::from(url.clone())) { - if let Some(credential) = self.credentials.get(&url_service) { - // If a username is provided, it must match - if username.is_none() || username == credential.username() { - return Some(credential); - } + if let Some(credential) = self.credentials.get(&( + url_service.clone(), + Username::from(username.map(str::to_string)), + )) { + return Some(credential); } } // If that fails, iterate through to find a prefix match let mut best: Option<(usize, &Service, &Credentials)> = None; - for (service, credential) in &self.credentials { + for ((service, stored_username), credential) in &self.credentials { let service_realm = Realm::from(service.url().deref()); // Only consider services in the same realm @@ -332,8 +341,10 @@ impl TextCredentialStore { } // If a username is provided, it must match - if username.is_some() && username != credential.username() { - continue; + if let Some(request_username) = username { + if Some(request_username) != stored_username.as_deref() { + continue; + } } // Update our best matching credential based on prefix length @@ -353,12 +364,17 @@ impl TextCredentialStore { /// Store credentials for a given service. pub fn insert(&mut self, service: Service, credentials: Credentials) -> Option { - self.credentials.insert(service, credentials) + let username = match &credentials { + Credentials::Basic { username, .. } => username.clone(), + Credentials::Bearer { .. } => Username::none(), + }; + self.credentials.insert((service, username), credentials) } /// Remove credentials for a given service. - pub fn remove(&mut self, service: &Service) -> Option { - self.credentials.remove(service) + pub fn remove(&mut self, service: &Service, username: Username) -> Option { + // Remove the specific credential for this service and username + self.credentials.remove(&(service.clone(), username)) } } @@ -419,7 +435,11 @@ mod tests { assert_eq!(retrieved.username(), Some("user")); assert_eq!(retrieved.password(), Some("pass")); - assert!(store.remove(&service).is_some()); + assert!( + store + .remove(&service, Username::from(Some("user".to_string()))) + .is_some() + ); let url = Url::parse("https://example.com/").unwrap(); assert!(store.get_credentials(&url, None).is_none()); } diff --git a/crates/uv/src/commands/auth/logout.rs b/crates/uv/src/commands/auth/logout.rs index fe7aef71f..58e2d11f1 100644 --- a/crates/uv/src/commands/auth/logout.rs +++ b/crates/uv/src/commands/auth/logout.rs @@ -3,9 +3,8 @@ use std::fmt::Write; use anyhow::{Context, Result, bail}; use owo_colors::OwoColorize; -use uv_auth::Service; use uv_auth::store::AuthBackend; -use uv_auth::{Credentials, TextCredentialStore}; +use uv_auth::{Credentials, Service, TextCredentialStore, Username}; use uv_distribution_types::IndexUrl; use uv_pep508::VerbatimUrl; use uv_preview::Preview; @@ -60,7 +59,10 @@ pub(crate) async fn logout( .with_context(|| format!("Unable to remove credentials for {display_url}"))?; } AuthBackend::TextStore(mut store, _lock) => { - if store.remove(&service).is_none() { + if store + .remove(&service, Username::from(Some(username.clone()))) + .is_none() + { bail!("No matching entry found for {display_url}"); } store diff --git a/crates/uv/tests/it/auth.rs b/crates/uv/tests/it/auth.rs index a568c3a82..7f8ac4490 100644 --- a/crates/uv/tests/it/auth.rs +++ b/crates/uv/tests/it/auth.rs @@ -1208,3 +1208,82 @@ fn token_text_store_username() { " ); } + +#[test] +fn logout_text_store_multiple_usernames() { + let context = TestContext::new_with_versions(&[]); + + // Login with two different usernames for the same service + context + .auth_login() + .arg("https://example.com/simple") + .arg("--username") + .arg("user1") + .arg("--password") + .arg("pass1") + .assert() + .success(); + + context + .auth_login() + .arg("https://example.com/simple") + .arg("--username") + .arg("user2") + .arg("--password") + .arg("pass2") + .assert() + .success(); + + // Logout one specific username + uv_snapshot!(context.auth_logout() + .arg("https://example.com/simple") + .arg("--username") + .arg("user1"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Removed credentials for user1@https://example.com/ + " + ); + + // Verify the first user is gone but second remains + uv_snapshot!(context.auth_token() + .arg("https://example.com/simple") + .arg("--username") + .arg("user1"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to fetch credentials for user1@https://example.com/simple + " + ); + + uv_snapshot!(context.auth_token() + .arg("https://example.com/simple") + .arg("--username") + .arg("user2"), @r" + success: true + exit_code: 0 + ----- stdout ----- + pass2 + + ----- stderr ----- + " + ); + + // Try to logout without specifying username (defaults to `__token__`) + uv_snapshot!(context.auth_logout() + .arg("https://example.com/simple"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No matching entry found for https://example.com/ + " + ); +}