Add support for credentials in URLs to `uv auth` (#15554)

Allows cases like `uv auth login https://username:password@example.com`
for coherence with the rest of our interfaces.
This commit is contained in:
Zanie Blue 2025-08-28 10:31:27 -05:00
parent 4ad5ae5e6f
commit a1cc12af2b
5 changed files with 301 additions and 60 deletions

View File

@ -84,6 +84,9 @@ impl KeyringProvider {
return Ok(false); return Ok(false);
}; };
// Ensure we strip credentials from the URL before storing
let url = url.without_credentials();
match &self.backend { match &self.backend {
KeyringProviderBackend::Native => { KeyringProviderBackend::Native => {
self.store_native(url.as_str(), username, password).await?; self.store_native(url.as_str(), username, password).await?;
@ -116,6 +119,9 @@ impl KeyringProvider {
/// Only [`KeyringProviderBackend::Native`] is supported at this time. /// Only [`KeyringProviderBackend::Native`] is supported at this time.
#[instrument(skip_all, fields(url = % url.to_string(), username))] #[instrument(skip_all, fields(url = % url.to_string(), username))]
pub async fn remove(&self, url: &DisplaySafeUrl, username: &str) -> Result<(), Error> { 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();
match &self.backend { match &self.backend {
KeyringProviderBackend::Native => { KeyringProviderBackend::Native => {
self.remove_native(url.as_str(), username).await?; self.remove_native(url.as_str(), username).await?;

View File

@ -19,24 +19,6 @@ pub(crate) async fn login(
preview: Preview, preview: Preview,
) -> Result<ExitStatus> { ) -> Result<ExitStatus> {
let url = service.url(); let url = service.url();
let display_url = username
.as_ref()
.map(|username| format!("{username}@{url}"))
.unwrap_or_else(|| url.to_string());
let username = if let Some(username) = username {
username
} else if token.is_some() {
String::from("__token__")
} else {
let term = Term::stderr();
if term.is_term() {
let prompt = "username: ";
uv_console::username(prompt, &term)?
} else {
bail!("No username provided; did you mean to provide `--username` or `--token`?");
}
};
// Be helpful about incompatible `keyring-provider` settings // Be helpful about incompatible `keyring-provider` settings
let Some(keyring_provider) = &keyring_provider else { let Some(keyring_provider) = &keyring_provider else {
@ -55,14 +37,65 @@ pub(crate) async fn login(
} }
}; };
// FIXME: It would be preferable to accept the value of --password or --token // Extract credentials from URL if present
// from stdin, perhaps checking here for `-` as an indicator to read stdin. We let url_credentials = Credentials::from_url(url);
// could then warn if the password is provided as a plaintext argument. let url_username = url_credentials.as_ref().and_then(|c| c.username());
let password = if let Some(password) = password { let url_password = url_credentials.as_ref().and_then(|c| c.password());
password
} else if let Some(token) = token { let username = match (username, url_username) {
token (Some(cli), Some(url)) => {
bail!(
"Cannot specify a username both via the URL and CLI; found `--username {cli}` and `{url}`"
);
}
(Some(cli), None) => Some(cli),
(None, Some(url)) => Some(url.to_string()),
(None, None) => {
// When using `--token`, we'll use a `__token__` placeholder username
if token.is_some() {
Some("__token__".to_string())
} else { } else {
None
}
}
};
// Ensure that a username is not provided when using a token
if token.is_some() {
if let Some(username) = &username {
if username != "__token__" {
bail!("When using `--token`, a username cannot not be provided; found: {username}");
}
}
}
// Prompt for a username if not provided
let username = if let Some(username) = username {
username
} else {
let term = Term::stderr();
if term.is_term() {
let prompt = "username: ";
uv_console::username(prompt, &term)?
} else {
bail!("No username provided; did you mean to provide `--username` or `--token`?");
}
};
let password = match (password, url_password, token) {
(Some(_), Some(_), _) => {
bail!("Cannot specify a password both via the URL and CLI");
}
(Some(_), None, Some(_)) => {
bail!("Cannot specify a password via `--password` when using `--token`");
}
(None, Some(_), Some(_)) => {
bail!("Cannot include a password in the URL when using `--token`")
}
(Some(cli), None, None) => cli,
(None, Some(url), None) => url.to_string(),
(None, None, Some(token)) => token,
(None, None, None) => {
let term = Term::stderr(); let term = Term::stderr();
if term.is_term() { if term.is_term() {
let prompt = "password: "; let prompt = "password: ";
@ -70,6 +103,13 @@ pub(crate) async fn login(
} else { } else {
bail!("No password provided; did you mean to provide `--password` or `--token`?"); bail!("No password provided; did you mean to provide `--password` or `--token`?");
} }
}
};
let display_url = if username == "__token__" {
url.without_credentials().to_string()
} else {
format!("{username}@{}", url.without_credentials())
}; };
// TODO(zanieb): Add support for other authentication schemes here, e.g., `Credentials::Bearer` // TODO(zanieb): Add support for other authentication schemes here, e.g., `Credentials::Bearer`
@ -77,6 +117,5 @@ pub(crate) async fn login(
provider.store(url, &credentials).await?; provider.store(url, &credentials).await?;
writeln!(printer.stderr(), "Logged in to {display_url}")?; writeln!(printer.stderr(), "Logged in to {display_url}")?;
Ok(ExitStatus::Success) Ok(ExitStatus::Success)
} }

View File

@ -1,5 +1,6 @@
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use std::{borrow::Cow, fmt::Write}; use std::fmt::Write;
use uv_auth::Credentials;
use uv_configuration::{KeyringProviderType, Service}; use uv_configuration::{KeyringProviderType, Service};
use uv_preview::Preview; use uv_preview::Preview;
@ -16,13 +17,27 @@ pub(crate) async fn logout(
preview: Preview, preview: Preview,
) -> Result<ExitStatus> { ) -> Result<ExitStatus> {
let url = service.url(); let url = service.url();
let display_url = username
.as_ref() // Extract credentials from URL if present
.map(|username| format!("{username}@{url}")) let url_credentials = Credentials::from_url(url);
.unwrap_or_else(|| url.to_string()); let url_username = url_credentials.as_ref().and_then(|c| c.username());
let username = username
.map(Cow::Owned) let username = match (username, url_username) {
.unwrap_or(Cow::Borrowed("__token__")); (Some(cli), Some(url)) => {
bail!(
"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(),
};
let display_url = if username == "__token__" {
url.without_credentials().to_string()
} else {
format!("{username}@{}", url.without_credentials())
};
// Unlike login, we'll default to the native provider if none is requested since it's the only // Unlike login, we'll default to the native provider if none is requested since it's the only
// valid option and it doesn't matter if the credentials are available in subsequent commands. // valid option and it doesn't matter if the credentials are available in subsequent commands.

View File

@ -2,10 +2,11 @@ use std::fmt::Write;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use uv_auth::Credentials;
use uv_configuration::{KeyringProviderType, Service}; use uv_configuration::{KeyringProviderType, Service};
use uv_preview::Preview; use uv_preview::Preview;
use crate::{Printer, commands::ExitStatus}; use crate::{commands::ExitStatus, printer::Printer};
/// Show the token that will be used for a service. /// Show the token that will be used for a service.
pub(crate) async fn token( pub(crate) async fn token(
@ -15,6 +16,7 @@ pub(crate) async fn token(
printer: Printer, printer: Printer,
preview: Preview, preview: Preview,
) -> Result<ExitStatus> { ) -> Result<ExitStatus> {
let url = service.url();
// Determine the keyring provider to use // Determine the keyring provider to use
let Some(keyring_provider) = &keyring_provider else { let Some(keyring_provider) = &keyring_provider else {
bail!("Retrieving credentials requires setting a `keyring-provider`"); bail!("Retrieving credentials requires setting a `keyring-provider`");
@ -23,21 +25,36 @@ pub(crate) async fn token(
bail!("Cannot retrieve credentials with `keyring-provider = {keyring_provider}`"); bail!("Cannot retrieve credentials with `keyring-provider = {keyring_provider}`");
}; };
let url = service.url(); // Extract credentials from URL if present
let display_url = username let url_credentials = Credentials::from_url(url);
.as_ref() let url_username = url_credentials.as_ref().and_then(|c| c.username());
.map(|username| format!("{username}@{url}"))
.unwrap_or_else(|| url.to_string()); let username = match (username, url_username) {
(Some(cli), Some(url)) => {
bail!(
"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(),
};
let display_url = if username == "__token__" {
url.without_credentials().to_string()
} else {
format!("{username}@{}", url.without_credentials())
};
let credentials = provider let credentials = provider
.fetch(url, Some(username.as_deref().unwrap_or("__token__"))) .fetch(url, Some(&username))
.await .await
.with_context(|| format!("Failed to fetch credentials for {display_url}"))?; .with_context(|| format!("Failed to fetch credentials for {display_url}"))?;
let Some(password) = credentials.password() else { let Some(password) = credentials.password() else {
bail!( bail!(
"No {} found for {display_url}", "No {} found for {display_url}",
if username.is_some() { if username != "__token__" {
"password" "password"
} else { } else {
"token" "token"

View File

@ -282,6 +282,41 @@ fn token_native_keyring() -> Result<()> {
warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning.
"); ");
context
.auth_logout()
.arg("https://pypi-proxy.fly.dev/basic-auth/simple")
.arg("--username")
.arg("public")
.status()?;
// Retrieve token using URL with embedded username (no --username needed)
uv_snapshot!(context.auth_token()
.arg("https://public@pypi-proxy.fly.dev/basic-auth/simple")
.arg("--keyring-provider")
.arg("native"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Failed to fetch credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple
");
// Conflict between --username and URL username is rejected
uv_snapshot!(context.auth_token()
.arg("https://public@pypi-proxy.fly.dev/basic-auth/simple")
.arg("--username")
.arg("different")
.arg("--keyring-provider")
.arg("native"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Cannot specify a username both via the URL and CLI; found `--username different` and `public`
");
Ok(()) Ok(())
} }
@ -299,7 +334,7 @@ fn token_subprocess_keyring() {
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
error: Failed to fetch credentials for https://****@pypi-proxy.fly.dev/basic-auth/simple error: Failed to fetch credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple
" "
); );
@ -327,9 +362,9 @@ fn token_subprocess_keyring() {
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
Keyring request for __token__@https://public@pypi-proxy.fly.dev/basic-auth/simple Keyring request for public@https://public@pypi-proxy.fly.dev/basic-auth/simple
Keyring request for __token__@pypi-proxy.fly.dev Keyring request for public@pypi-proxy.fly.dev
error: Failed to fetch credentials for https://****@pypi-proxy.fly.dev/basic-auth/simple error: Failed to fetch credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple
" "
); );
@ -341,14 +376,14 @@ fn token_subprocess_keyring() {
.arg("subprocess") .arg("subprocess")
.env(EnvVars::KEYRING_TEST_CREDENTIALS, r#"{"pypi-proxy.fly.dev": {"public": "heron"}}"#) .env(EnvVars::KEYRING_TEST_CREDENTIALS, r#"{"pypi-proxy.fly.dev": {"public": "heron"}}"#)
.env(EnvVars::PATH, venv_bin_path(&context.venv)), @r" .env(EnvVars::PATH, venv_bin_path(&context.venv)), @r"
success: false success: true
exit_code: 2 exit_code: 0
----- stdout ----- ----- stdout -----
heron
----- stderr ----- ----- stderr -----
Keyring request for __token__@https://public@pypi-proxy.fly.dev/basic-auth/simple Keyring request for public@https://public@pypi-proxy.fly.dev/basic-auth/simple
Keyring request for __token__@pypi-proxy.fly.dev Keyring request for public@pypi-proxy.fly.dev
error: Failed to fetch credentials for https://****@pypi-proxy.fly.dev/basic-auth/simple
" "
); );
@ -361,14 +396,12 @@ fn token_subprocess_keyring() {
.arg("public") .arg("public")
.env(EnvVars::KEYRING_TEST_CREDENTIALS, r#"{"pypi-proxy.fly.dev": {"public": "heron"}}"#) .env(EnvVars::KEYRING_TEST_CREDENTIALS, r#"{"pypi-proxy.fly.dev": {"public": "heron"}}"#)
.env(EnvVars::PATH, venv_bin_path(&context.venv)), @r" .env(EnvVars::PATH, venv_bin_path(&context.venv)), @r"
success: true success: false
exit_code: 0 exit_code: 2
----- stdout ----- ----- stdout -----
heron
----- stderr ----- ----- stderr -----
Keyring request for public@https://public@pypi-proxy.fly.dev/basic-auth/simple error: Cannot specify a username both via the URL and CLI; found `--username public` and `public`
Keyring request for public@pypi-proxy.fly.dev
" "
); );
} }
@ -618,6 +651,62 @@ fn logout_native_keyring() -> Result<()> {
Logged out of public@https://pypi-proxy.fly.dev/basic-auth/simple Logged out of public@https://pypi-proxy.fly.dev/basic-auth/simple
"); ");
// Login again
context
.auth_login()
.arg("https://pypi-proxy.fly.dev/basic-auth/simple")
.arg("--username")
.arg("public")
.arg("--password")
.arg("heron")
.arg("--keyring-provider")
.arg("native")
.assert()
.success();
// Logout with a username in the URL
uv_snapshot!(context.auth_logout()
.arg("https://public@pypi-proxy.fly.dev/basic-auth/simple")
.arg("--keyring-provider")
.arg("native"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Logged out of public@https://pypi-proxy.fly.dev/basic-auth/simple
");
// Conflict between --username and a URL username is rejected
uv_snapshot!(context.auth_logout()
.arg("https://public@pypi-proxy.fly.dev/basic-auth/simple")
.arg("--username")
.arg("foo")
.arg("--keyring-provider")
.arg("native"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Cannot specify a username both via the URL and CLI; found `--username foo` and `public`
");
// Conflict between --token and a URL username is rejected
uv_snapshot!(context.auth_login()
.arg("https://public@pypi-proxy.fly.dev/basic-auth/simple")
.arg("--token")
.arg("foo")
.arg("--keyring-provider")
.arg("native"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: When using `--token`, a username cannot not be provided; found: public
");
Ok(()) Ok(())
} }
@ -666,7 +755,7 @@ fn logout_token_native_keyring() -> Result<()> {
} }
#[test] #[test]
fn login_url_parsing() { fn login_native_keyring_url() {
let context = TestContext::new_with_versions(&[]).with_real_home(); let context = TestContext::new_with_versions(&[]).with_real_home();
// A domain-only service name gets https:// prepended // A domain-only service name gets https:// prepended
@ -758,4 +847,79 @@ fn login_url_parsing() {
For more information, try '--help'. For more information, try '--help'.
"); ");
// URL with embedded credentials works
uv_snapshot!(context.auth_login()
.arg("https://test:password@example.com/simple")
.arg("--keyring-provider")
.arg("native"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Logged in to test@https://example.com/simple
");
// URL with embedded username and separate password works
uv_snapshot!(context.auth_login()
.arg("https://test@example.com/simple")
.arg("--password")
.arg("password")
.arg("--keyring-provider")
.arg("native"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Logged in to test@https://example.com/simple
");
// Conflict between --username and URL username is rejected
uv_snapshot!(context.auth_login()
.arg("https://test@example.com/simple")
.arg("--username")
.arg("different")
.arg("--password")
.arg("password")
.arg("--keyring-provider")
.arg("native"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Cannot specify a username both via the URL and CLI; found `--username different` and `test`
");
// Conflict between --password and URL password is rejected
uv_snapshot!(context.auth_login()
.arg("https://test:password@example.com/simple")
.arg("--password")
.arg("different")
.arg("--keyring-provider")
.arg("native"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Cannot specify a password both via the URL and CLI
");
// Conflict between --token and URL credentials is rejected
uv_snapshot!(context.auth_login()
.arg("https://test:password@example.com/simple")
.arg("--token")
.arg("some-token")
.arg("--keyring-provider")
.arg("native"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: When using `--token`, a username cannot not be provided; found: test
");
} }