Skip to content

Commit c16b2cb

Browse files
committed
Log out without --username if username is unambiguous
1 parent 8ae8cc3 commit c16b2cb

5 files changed

Lines changed: 309 additions & 37 deletions

File tree

crates/uv-auth/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ pub use pyx::{
1010
};
1111
pub use realm::{Realm, RealmRef};
1212
pub use service::{Service, ServiceParseError};
13-
pub use store::{AuthBackend, AuthScheme, TextCredentialStore, TomlCredentialError};
13+
pub use store::{AuthBackend, AuthScheme, LookupError, TextCredentialStore, TomlCredentialError};
1414

1515
mod access_token;
1616
mod cache;

crates/uv-auth/src/store.rs

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::borrow::Cow;
12
use std::ops::Deref;
23
use std::path::{Path, PathBuf};
34

@@ -333,14 +334,14 @@ impl TextCredentialStore {
333334
Ok(())
334335
}
335336

336-
/// Get credentials for a given URL and username.
337+
/// Get the credential entry for a given URL and username.
337338
///
338339
/// The most specific URL prefix match in the same [`Realm`] is returned, if any.
339-
pub fn get_credentials(
340+
pub fn get_credential_entry(
340341
&self,
341342
url: &DisplaySafeUrl,
342343
username: Option<&str>,
343-
) -> Result<Option<&Credentials>, LookupError> {
344+
) -> Result<Option<(Cow<'_, Service>, &Credentials)>, LookupError> {
344345
let request_realm = Realm::from(url);
345346

346347
// Perform an exact lookup first
@@ -351,7 +352,7 @@ impl TextCredentialStore {
351352
url_service.clone(),
352353
Username::from(username.map(str::to_string)),
353354
)) {
354-
return Ok(Some(credential));
355+
return Ok(Some((Cow::Owned(url_service), credential)));
355356
}
356357
}
357358

@@ -388,13 +389,26 @@ impl TextCredentialStore {
388389
}
389390

390391
// Return the most specific match
391-
if let Some((_, _, credential)) = best {
392-
return Ok(Some(credential));
392+
if let Some((_, service, credential)) = best {
393+
return Ok(Some((Cow::Borrowed(service), credential)));
393394
}
394395

395396
Ok(None)
396397
}
397398

399+
/// Get credentials for a given URL and username.
400+
///
401+
/// The most specific URL prefix match in the same [`Realm`] is returned, if any.
402+
pub fn get_credentials(
403+
&self,
404+
url: &DisplaySafeUrl,
405+
username: Option<&str>,
406+
) -> Result<Option<&Credentials>, LookupError> {
407+
Ok(self
408+
.get_credential_entry(url, username)?
409+
.map(|(.., credential)| credential))
410+
}
411+
398412
/// Store credentials for a given service.
399413
pub fn insert(&mut self, service: Service, credentials: Credentials) -> Option<Credentials> {
400414
let username = match &credentials {

crates/uv/src/commands/auth/logout.rs

Lines changed: 90 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ use std::fmt::Write;
22

33
use anyhow::{Context, Result, bail};
44
use owo_colors::OwoColorize;
5+
use url::Url;
56

67
use uv_auth::{
7-
AuthBackend, Credentials, PyxTokenStore, Service, TextCredentialStore, Username,
8+
AuthBackend, Credentials, LookupError, PyxTokenStore, Service, TextCredentialStore, Username,
89
is_default_pyx_domain,
910
};
1011
use uv_client::BaseClientBuilder;
@@ -16,7 +17,7 @@ use crate::{commands::ExitStatus, printer::Printer};
1617

1718
/// Logout from a service.
1819
///
19-
/// If no username is provided, defaults to `__token__`.
20+
/// If no username is provided, uv tries the default token entry first.
2021
pub(crate) async fn logout(
2122
service: Service,
2223
username: Option<String>,
@@ -48,40 +49,85 @@ pub(crate) async fn logout(
4849
"Cannot specify a username both via the URL and CLI; found `--username {cli}` and `{url}`"
4950
);
5051
}
51-
(Some(cli), None) => cli,
52-
(None, Some(url)) => url.to_string(),
53-
(None, None) => "__token__".to_string(),
52+
(Some(cli), None) => Some(cli),
53+
(None, Some(url)) => Some(url.to_string()),
54+
(None, None) => None,
5455
};
55-
if username.is_empty() {
56+
if username.as_ref().is_some_and(String::is_empty) {
5657
bail!("Username cannot be empty");
5758
}
5859

59-
let display_url = if username == "__token__" {
60-
url.without_credentials().to_string()
61-
} else {
62-
format!("{username}@{}", url.without_credentials())
63-
};
60+
let url_without_credentials = url.without_credentials();
6461

6562
// TODO(zanieb): Consider exhaustively logging out from all backends
66-
match backend {
63+
let display_url = match backend {
6764
AuthBackend::System(provider) => {
68-
provider
69-
.remove(&url, &username)
70-
.await
71-
.with_context(|| format!("Unable to remove credentials for {display_url}"))?;
65+
if let Some(username) = username.as_deref() {
66+
let display_url = format_display_url(&url_without_credentials, Some(username));
67+
provider
68+
.remove(&url, username)
69+
.await
70+
.with_context(|| format!("Unable to remove credentials for {display_url}"))?;
71+
display_url
72+
} else if provider.fetch(&url, Some("__token__")).await.is_some() {
73+
provider.remove(&url, "__token__").await.with_context(|| {
74+
format!("Unable to remove credentials for {url_without_credentials}")
75+
})?;
76+
url_without_credentials.to_string()
77+
} else {
78+
bail!("{}", missing_username_hint(&url_without_credentials));
79+
}
7280
}
7381
AuthBackend::TextStore(mut store, _lock) => {
74-
if store
75-
.remove(&service, Username::from(Some(username.clone())))
76-
.is_none()
82+
let display_url = if let Some(username) = username {
83+
let display_url = format_display_url(&url_without_credentials, Some(&username));
84+
if store
85+
.remove(&service, Username::from(Some(username)))
86+
.is_none()
87+
{
88+
bail!("No matching entry found for {display_url}");
89+
}
90+
display_url
91+
} else if store
92+
.remove(&service, Username::from(Some("__token__".to_string())))
93+
.is_some()
7794
{
78-
bail!("No matching entry found for {display_url}");
79-
}
95+
url_without_credentials.to_string()
96+
} else {
97+
let lookup = store.get_credential_entry(&url, None).map(|entry| {
98+
entry.map(|(matched_service, credentials)| {
99+
(
100+
matched_service.into_owned(),
101+
credentials.username().map(ToString::to_string),
102+
)
103+
})
104+
});
105+
match lookup {
106+
Ok(Some((matched_service, matched_username))) => {
107+
let display_url = format_display_url(
108+
&url_without_credentials,
109+
matched_username.as_deref(),
110+
);
111+
if store
112+
.remove(&matched_service, Username::from(matched_username))
113+
.is_none()
114+
{
115+
bail!("No matching entry found for {display_url}");
116+
}
117+
display_url
118+
}
119+
Ok(None) => bail!("{}", missing_username_hint(&url_without_credentials)),
120+
Err(LookupError::AmbiguousUsername(..)) => {
121+
bail!("{}", ambiguous_username_hint(&url_without_credentials))
122+
}
123+
}
124+
};
80125
store
81126
.write(TextCredentialStore::default_file()?, _lock)
82127
.with_context(|| "Failed to persist changes to credentials after removal")?;
128+
display_url
83129
}
84-
}
130+
};
85131

86132
writeln!(
87133
printer.stderr(),
@@ -92,6 +138,28 @@ pub(crate) async fn logout(
92138
Ok(ExitStatus::Success)
93139
}
94140

141+
/// Format a URL for display, including the username if it's present (and not the default token
142+
/// entry).
143+
fn format_display_url(url: &Url, username: Option<&str>) -> String {
144+
if let Some(username) = username.filter(|username| *username != "__token__") {
145+
format!("{username}@{url}")
146+
} else {
147+
url.to_string()
148+
}
149+
}
150+
151+
fn missing_username_hint(display_url: &Url) -> String {
152+
format!(
153+
"No matching entry found for {display_url}. If the credentials were stored with a username, pass `--username` to `uv auth logout`."
154+
)
155+
}
156+
157+
fn ambiguous_username_hint(display_url: &Url) -> String {
158+
format!(
159+
"Multiple credentials found for {display_url}. Pass `--username` to `uv auth logout` to select which credentials to remove."
160+
)
161+
}
162+
95163
/// Log out via the [`PyxTokenStore`], invalidating the existing tokens.
96164
async fn pyx_logout(
97165
store: &PyxTokenStore,

0 commit comments

Comments
 (0)