mirror of https://github.com/astral-sh/uv
Add a plain text backend for credential storage (#15588)
Adds a default plain text storage mechanism to `uv auth`. While we'd prefer to use the system store, the "native" keyring support is experimental still and I don't want to ship an unusable interface. @geofft also suggested that the story for secure credential storage is much weaker on Linux than macOS and Windows and felt this approach would be needed regardless. We'll switch over to using the native keyring by default in the future. On Linux, we can now fallback to a plaintext store the secret store is not configured, which is a nice property. Right now, we store credentials in a TOML file in the uv state directory. I expect to also read from the uv config directory in the future, but we don't need it immediately.
This commit is contained in:
parent
ddf2f5ed8c
commit
ac5dc9be1f
|
|
@ -5077,6 +5077,7 @@ dependencies = [
|
|||
"anyhow",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"fs-err",
|
||||
"futures",
|
||||
"http",
|
||||
"insta",
|
||||
|
|
@ -5091,8 +5092,10 @@ dependencies = [
|
|||
"test-log",
|
||||
"thiserror 2.0.16",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
"url",
|
||||
"uv-dirs",
|
||||
"uv-keyring",
|
||||
"uv-once-map",
|
||||
"uv-redacted",
|
||||
|
|
@ -5307,6 +5310,7 @@ dependencies = [
|
|||
"insta",
|
||||
"serde",
|
||||
"url",
|
||||
"uv-auth",
|
||||
"uv-cache",
|
||||
"uv-configuration",
|
||||
"uv-distribution-types",
|
||||
|
|
@ -5411,7 +5415,6 @@ dependencies = [
|
|||
"uv-pep508",
|
||||
"uv-platform-tags",
|
||||
"uv-preview",
|
||||
"uv-redacted",
|
||||
"uv-static",
|
||||
"uv-warnings",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ doctest = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
uv-dirs = { workspace = true }
|
||||
uv-keyring = { workspace = true, features = ["apple-native", "secret-service", "windows-native"] }
|
||||
uv-once-map = { workspace = true }
|
||||
uv-redacted = { workspace = true }
|
||||
|
|
@ -20,6 +21,7 @@ uv-warnings = { workspace = true }
|
|||
anyhow = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
fs-err = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
http = { workspace = true }
|
||||
percent-encoding = { workspace = true }
|
||||
|
|
@ -31,6 +33,7 @@ schemars = { workspace = true, optional = true }
|
|||
serde = { workspace = true, features = ["derive"] }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use base64::prelude::BASE64_STANDARD;
|
||||
use base64::read::DecoderReader;
|
||||
use base64::write::EncoderWriter;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::borrow::Cow;
|
||||
use std::fmt;
|
||||
use uv_redacted::DisplaySafeUrl;
|
||||
|
|
@ -28,7 +29,8 @@ pub enum Credentials {
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Ord, PartialOrd, Hash, Default)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Ord, PartialOrd, Hash, Default, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Username(Option<String>);
|
||||
|
||||
impl Username {
|
||||
|
|
@ -69,7 +71,8 @@ impl From<Option<String>> for Username {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Default)]
|
||||
#[derive(Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Default, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Password(String);
|
||||
|
||||
impl Password {
|
||||
|
|
@ -80,6 +83,10 @@ impl Password {
|
|||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Password {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ pub use index::{AuthPolicy, Index, Indexes};
|
|||
pub use keyring::KeyringProvider;
|
||||
pub use middleware::AuthMiddleware;
|
||||
use realm::Realm;
|
||||
pub use service::{Service, ServiceParseError};
|
||||
pub use store::{AuthScheme, TextCredentialStore, TomlCredentialError};
|
||||
use uv_redacted::DisplaySafeUrl;
|
||||
|
||||
mod cache;
|
||||
|
|
@ -17,6 +19,8 @@ mod keyring;
|
|||
mod middleware;
|
||||
mod providers;
|
||||
mod realm;
|
||||
mod service;
|
||||
pub mod store;
|
||||
|
||||
// TODO(zanieb): Consider passing a cache explicitly throughout
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ use reqwest::{Request, Response};
|
|||
use reqwest_middleware::{Error, Middleware, Next};
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
use uv_redacted::DisplaySafeUrl;
|
||||
|
||||
use crate::providers::HuggingFaceProvider;
|
||||
use crate::{
|
||||
CREDENTIALS_CACHE, CredentialsCache, KeyringProvider,
|
||||
|
|
@ -15,7 +17,7 @@ use crate::{
|
|||
index::{AuthPolicy, Indexes},
|
||||
realm::Realm,
|
||||
};
|
||||
use uv_redacted::DisplaySafeUrl;
|
||||
use crate::{TextCredentialStore, TomlCredentialError};
|
||||
|
||||
/// Strategy for loading netrc files.
|
||||
enum NetrcMode {
|
||||
|
|
@ -51,12 +53,64 @@ impl NetrcMode {
|
|||
}
|
||||
}
|
||||
|
||||
/// Strategy for loading text-based credential files.
|
||||
enum TextStoreMode {
|
||||
Automatic(LazyLock<Option<TextCredentialStore>>),
|
||||
Enabled(TextCredentialStore),
|
||||
Disabled,
|
||||
}
|
||||
|
||||
impl Default for TextStoreMode {
|
||||
fn default() -> Self {
|
||||
// TODO(zanieb): Reconsider this pattern. We're just mirroring the [`NetrcMode`]
|
||||
// implementation for now.
|
||||
Self::Automatic(LazyLock::new(|| {
|
||||
let state_dir = uv_dirs::user_state_dir()?;
|
||||
let credentials_path = state_dir.join("credentials").join("credentials.toml");
|
||||
|
||||
match TextCredentialStore::from_file(&credentials_path) {
|
||||
Ok(store) => {
|
||||
debug!("Loaded credential file {}", credentials_path.display());
|
||||
Some(store)
|
||||
}
|
||||
Err(TomlCredentialError::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
debug!(
|
||||
"No credentials file found at {}",
|
||||
credentials_path.display()
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Failed to load credentials from {}: {}",
|
||||
credentials_path.display(),
|
||||
err
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl TextStoreMode {
|
||||
/// Get the parsed credential store if enabled.
|
||||
fn get(&self) -> Option<&TextCredentialStore> {
|
||||
match self {
|
||||
Self::Automatic(lock) => lock.as_ref(),
|
||||
Self::Enabled(store) => Some(store),
|
||||
Self::Disabled => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A middleware that adds basic authentication to requests.
|
||||
///
|
||||
/// Uses a cache to propagate credentials from previously seen requests and
|
||||
/// fetches credentials from a netrc file and the keyring.
|
||||
/// fetches credentials from a netrc file, TOML file, and the keyring.
|
||||
pub struct AuthMiddleware {
|
||||
netrc: NetrcMode,
|
||||
text_store: TextStoreMode,
|
||||
keyring: Option<KeyringProvider>,
|
||||
cache: Option<CredentialsCache>,
|
||||
/// Auth policies for specific URLs.
|
||||
|
|
@ -70,6 +124,7 @@ impl AuthMiddleware {
|
|||
pub fn new() -> Self {
|
||||
Self {
|
||||
netrc: NetrcMode::default(),
|
||||
text_store: TextStoreMode::default(),
|
||||
keyring: None,
|
||||
cache: None,
|
||||
indexes: Indexes::new(),
|
||||
|
|
@ -90,6 +145,19 @@ impl AuthMiddleware {
|
|||
self
|
||||
}
|
||||
|
||||
/// Configure the text credential store to use.
|
||||
///
|
||||
/// `None` disables authentication via text store.
|
||||
#[must_use]
|
||||
pub fn with_text_store(mut self, store: Option<TextCredentialStore>) -> Self {
|
||||
self.text_store = if let Some(store) = store {
|
||||
TextStoreMode::Enabled(store)
|
||||
} else {
|
||||
TextStoreMode::Disabled
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
/// Configure the [`KeyringProvider`] to use.
|
||||
#[must_use]
|
||||
pub fn with_keyring(mut self, keyring: Option<KeyringProvider>) -> Self {
|
||||
|
|
@ -525,6 +593,14 @@ impl AuthMiddleware {
|
|||
debug!("Found credentials in netrc file for {url}");
|
||||
Some(credentials)
|
||||
|
||||
// Text credential store support.
|
||||
} else if let Some(credentials) = self.text_store.get().and_then(|text_store| {
|
||||
debug!("Checking text store for credentials for {url}");
|
||||
text_store.get_credentials(&url::Url::from(url.clone())).cloned()
|
||||
}) {
|
||||
debug!("Found credentials in text 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
|
||||
|
|
@ -2141,6 +2217,61 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test(tokio::test)]
|
||||
async fn test_text_store_basic_auth() -> Result<(), Error> {
|
||||
let username = "user";
|
||||
let password = "password";
|
||||
|
||||
let server = start_test_server(username, password).await;
|
||||
let base_url = Url::parse(&server.uri())?;
|
||||
|
||||
// Create a text credential store with matching credentials
|
||||
let mut store = TextCredentialStore::default();
|
||||
let service = crate::Service::try_from(base_url.to_string()).unwrap();
|
||||
let credentials =
|
||||
crate::Credentials::basic(Some(username.to_string()), Some(password.to_string()));
|
||||
store.insert(service.clone(), credentials);
|
||||
|
||||
let client = test_client_builder()
|
||||
.with(
|
||||
AuthMiddleware::new()
|
||||
.with_cache(CredentialsCache::new())
|
||||
.with_text_store(Some(store)),
|
||||
)
|
||||
.build();
|
||||
|
||||
assert_eq!(
|
||||
client.get(server.uri()).send().await?.status(),
|
||||
200,
|
||||
"Credentials should be pulled from the text store"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test(tokio::test)]
|
||||
async fn test_text_store_disabled() -> Result<(), Error> {
|
||||
let username = "user";
|
||||
let password = "password";
|
||||
let server = start_test_server(username, password).await;
|
||||
|
||||
let client = test_client_builder()
|
||||
.with(
|
||||
AuthMiddleware::new()
|
||||
.with_cache(CredentialsCache::new())
|
||||
.with_text_store(None), // Explicitly disable text store
|
||||
)
|
||||
.build();
|
||||
|
||||
assert_eq!(
|
||||
client.get(server.uri()).send().await?.status(),
|
||||
401,
|
||||
"Credentials should not be found when text store is disabled"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_request(url: &str) -> Request {
|
||||
Request::new(Method::GET, Url::parse(url).unwrap())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::str::FromStr;
|
||||
use thiserror::Error;
|
||||
use uv_redacted::DisplaySafeUrl;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ServiceParseError {
|
||||
#[error("failed to parse URL: {0}")]
|
||||
InvalidUrl(#[from] url::ParseError),
|
||||
#[error("only HTTPS is supported")]
|
||||
UnsupportedScheme,
|
||||
}
|
||||
|
||||
/// A service URL that wraps [`DisplaySafeUrl`] for CLI usage.
|
||||
///
|
||||
/// This type provides automatic URL parsing and validation when used as a CLI argument,
|
||||
/// eliminating the need for manual parsing in command functions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(transparent)]
|
||||
pub struct Service(DisplaySafeUrl);
|
||||
|
||||
impl Service {
|
||||
/// Get the underlying [`DisplaySafeUrl`].
|
||||
pub fn url(&self) -> &DisplaySafeUrl {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Convert into the underlying [`DisplaySafeUrl`].
|
||||
pub fn into_url(self) -> DisplaySafeUrl {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Validate that the URL scheme is supported.
|
||||
fn check_scheme(url: &DisplaySafeUrl) -> Result<(), ServiceParseError> {
|
||||
match url.scheme() {
|
||||
"https" => Ok(()),
|
||||
#[cfg(test)]
|
||||
"http" => Ok(()),
|
||||
_ => Err(ServiceParseError::UnsupportedScheme),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Service {
|
||||
type Err = ServiceParseError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
// First try parsing as-is
|
||||
let url = match DisplaySafeUrl::parse(s) {
|
||||
Ok(url) => url,
|
||||
Err(url::ParseError::RelativeUrlWithoutBase) => {
|
||||
// If it's a relative URL, try prepending https://
|
||||
let with_https = format!("https://{s}");
|
||||
DisplaySafeUrl::parse(&with_https)?
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
|
||||
Self::check_scheme(&url)?;
|
||||
|
||||
Ok(Self(url))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Service {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Service {
|
||||
type Error = ServiceParseError;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::from_str(&value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Service> for String {
|
||||
fn from(service: Service) -> Self {
|
||||
service.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<DisplaySafeUrl> for Service {
|
||||
type Error = ServiceParseError;
|
||||
|
||||
fn try_from(value: DisplaySafeUrl) -> Result<Self, Self::Error> {
|
||||
Self::check_scheme(&value)?;
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,527 @@
|
|||
use std::ops::Deref;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fs_err as fs;
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tracing::debug;
|
||||
use url::Url;
|
||||
use uv_redacted::DisplaySafeUrl;
|
||||
|
||||
use crate::Credentials;
|
||||
use crate::credentials::{Password, Username};
|
||||
use crate::realm::Realm;
|
||||
use crate::service::Service;
|
||||
|
||||
/// Authentication scheme to use.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuthScheme {
|
||||
/// HTTP Basic Authentication
|
||||
///
|
||||
/// Uses a username and password.
|
||||
Basic,
|
||||
/// Bearer token authentication.
|
||||
///
|
||||
/// Uses a token provided as `Bearer <token>` in the `Authorization` header.
|
||||
Bearer,
|
||||
}
|
||||
|
||||
impl Default for AuthScheme {
|
||||
fn default() -> Self {
|
||||
Self::Basic
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can occur when working with TOML credential storage.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TomlCredentialError {
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Failed to parse TOML credential file: {0}")]
|
||||
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),
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
/// A single credential entry in a TOML credentials file.
|
||||
// TODO(zanieb): It's a little clunky that we need don't nest the scheme-specific fields under a
|
||||
// that scheme, but I want the username / password case to be easily accessible without
|
||||
// understanding authentication schemes. We should consider a better structure here, e.g., by
|
||||
// adding an internal type that we cast to after validation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TomlCredential {
|
||||
/// The service URL for this credential.
|
||||
pub service: Service,
|
||||
/// The username to use. Only allowed with [`AuthScheme::Basic`].
|
||||
pub username: Username,
|
||||
/// The authentication scheme.
|
||||
#[serde(default)]
|
||||
pub scheme: AuthScheme,
|
||||
/// The password to use. Only allowed with [`AuthScheme::Basic`].
|
||||
pub password: Option<Password>,
|
||||
/// The token to use. Only allowed with [`AuthScheme::Bearer`].
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
impl TomlCredential {
|
||||
/// Validate that the credential configuration is correct for the scheme.
|
||||
fn validate(&self) -> Result<(), TomlCredentialError> {
|
||||
match self.scheme {
|
||||
AuthScheme::Basic => {
|
||||
if self.username.as_deref().is_none() {
|
||||
return Err(TomlCredentialError::BasicAuthError(
|
||||
BasicAuthError::MissingUsername,
|
||||
));
|
||||
}
|
||||
if self.token.is_some() {
|
||||
return Err(TomlCredentialError::BasicAuthError(
|
||||
BasicAuthError::UnexpectedToken,
|
||||
));
|
||||
}
|
||||
}
|
||||
AuthScheme::Bearer => {
|
||||
if self.username.is_some() {
|
||||
return Err(TomlCredentialError::BearerAuthError(
|
||||
BearerAuthError::UnexpectedUsername,
|
||||
));
|
||||
}
|
||||
if self.password.is_some() {
|
||||
return Err(TomlCredentialError::BearerAuthError(
|
||||
BearerAuthError::UnexpectedPassword,
|
||||
));
|
||||
}
|
||||
if self.token.is_none() {
|
||||
return Err(TomlCredentialError::BearerAuthError(
|
||||
BearerAuthError::MissingToken,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert to [`Credentials`].
|
||||
///
|
||||
/// This method can panic if [`TomlCredential::validate`] has not been called.
|
||||
pub fn into_credentials(self) -> Credentials {
|
||||
match self.scheme {
|
||||
AuthScheme::Basic => Credentials::Basic {
|
||||
username: self.username,
|
||||
password: self.password,
|
||||
},
|
||||
AuthScheme::Bearer => Credentials::Bearer {
|
||||
token: self.token.unwrap().into_bytes(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a [`TomlCredential`] for a service from [`Credentials`].
|
||||
pub fn from_credentials(
|
||||
service: Service,
|
||||
credentials: Credentials,
|
||||
) -> Result<Self, TomlCredentialError> {
|
||||
match credentials {
|
||||
Credentials::Basic { username, password } => Ok(Self {
|
||||
service,
|
||||
username,
|
||||
scheme: AuthScheme::Basic,
|
||||
password,
|
||||
token: None,
|
||||
}),
|
||||
Credentials::Bearer { token } => Ok(Self {
|
||||
service,
|
||||
username: Username::new(None),
|
||||
scheme: AuthScheme::Bearer,
|
||||
password: None,
|
||||
token: Some(String::from_utf8(token)?),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TomlCredentials {
|
||||
/// Array of credential entries.
|
||||
#[serde(rename = "credential")]
|
||||
pub credentials: Vec<TomlCredential>,
|
||||
}
|
||||
|
||||
/// A credential store with a plain text storage backend.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TextCredentialStore {
|
||||
credentials: FxHashMap<Service, Credentials>,
|
||||
}
|
||||
|
||||
impl TextCredentialStore {
|
||||
/// Return the default credential file path.
|
||||
pub fn default_file() -> Result<PathBuf, TomlCredentialError> {
|
||||
let state_dir =
|
||||
uv_dirs::user_state_dir().ok_or(TomlCredentialError::CredentialsDirError)?;
|
||||
let credentials_dir = state_dir.join("credentials");
|
||||
Ok(credentials_dir.join("credentials.toml"))
|
||||
}
|
||||
|
||||
/// Read credentials from a file.
|
||||
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, TomlCredentialError> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let credentials: TomlCredentials = toml::from_str(&content)?;
|
||||
|
||||
let credentials: FxHashMap<Service, Credentials> = credentials
|
||||
.credentials
|
||||
.into_iter()
|
||||
.filter_map(|credential| {
|
||||
// TODO(zanieb): Determine a better strategy for invalid credential entries
|
||||
if let Err(err) = credential.validate() {
|
||||
debug!(
|
||||
"Skipping invalid credential for {}: {}",
|
||||
credential.service, err
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((credential.service.clone(), credential.into_credentials()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Self { credentials })
|
||||
}
|
||||
|
||||
/// Persist credentials to a file.
|
||||
pub fn write<P: AsRef<Path>>(self, path: P) -> Result<(), TomlCredentialError> {
|
||||
let credentials = self
|
||||
.credentials
|
||||
.into_iter()
|
||||
.map(|(service, cred)| TomlCredential::from_credentials(service, cred))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let toml_creds = TomlCredentials { credentials };
|
||||
let content = toml::to_string_pretty(&toml_creds)?;
|
||||
fs::create_dir_all(
|
||||
path.as_ref()
|
||||
.parent()
|
||||
.ok_or(TomlCredentialError::CredentialsDirError)?,
|
||||
)?;
|
||||
|
||||
// TODO(zanieb): We should use an atomic write here
|
||||
fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get credentials for a given URL.
|
||||
/// Uses realm-based prefix matching following RFC 7235 and 7230 specifications.
|
||||
/// Credentials are matched by finding the most specific prefix that matches the request URL.
|
||||
pub fn get_credentials(&self, url: &Url) -> Option<&Credentials> {
|
||||
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(DisplaySafeUrl::from(url.clone())) {
|
||||
if let Some(credential) = self.credentials.get(&url_service) {
|
||||
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 {
|
||||
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 request path
|
||||
if !url.path().starts_with(service.url().path()) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
// Return the most specific match
|
||||
if let Some((_, _, credential)) = best {
|
||||
return Some(credential);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Store credentials for a given service.
|
||||
pub fn insert(&mut self, service: Service, credentials: Credentials) -> Option<Credentials> {
|
||||
self.credentials.insert(service, credentials)
|
||||
}
|
||||
|
||||
/// Remove credentials for a given service.
|
||||
pub fn remove(&mut self, service: &Service) -> Option<Credentials> {
|
||||
self.credentials.remove(service)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use std::str::FromStr;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[test]
|
||||
fn test_toml_credential_conversion() {
|
||||
let toml_cred = TomlCredential {
|
||||
service: Service::from_str("https://example.com").unwrap(),
|
||||
username: Username::new(Some("user".to_string())),
|
||||
scheme: AuthScheme::Basic,
|
||||
password: Some(Password::new("pass".to_string())),
|
||||
token: None,
|
||||
};
|
||||
|
||||
let credentials = toml_cred.into_credentials();
|
||||
assert_eq!(credentials.username(), Some("user"));
|
||||
assert_eq!(credentials.password(), Some("pass"));
|
||||
|
||||
let back_to_toml = TomlCredential::from_credentials(
|
||||
Service::from_str("https://example.com").unwrap(),
|
||||
credentials,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(back_to_toml.service.to_string(), "https://example.com/");
|
||||
assert_eq!(back_to_toml.username.as_deref(), Some("user"));
|
||||
assert_eq!(back_to_toml.password.as_ref().unwrap().as_str(), "pass");
|
||||
assert_eq!(back_to_toml.scheme, AuthScheme::Basic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toml_serialization() {
|
||||
let credentials = TomlCredentials {
|
||||
credentials: vec![
|
||||
TomlCredential {
|
||||
service: Service::from_str("https://example.com").unwrap(),
|
||||
username: Username::new(Some("user1".to_string())),
|
||||
scheme: AuthScheme::Basic,
|
||||
password: Some(Password::new("pass1".to_string())),
|
||||
token: None,
|
||||
},
|
||||
TomlCredential {
|
||||
service: Service::from_str("https://test.org").unwrap(),
|
||||
username: Username::new(Some("user2".to_string())),
|
||||
scheme: AuthScheme::Basic,
|
||||
password: Some(Password::new("pass2".to_string())),
|
||||
token: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let toml_str = toml::to_string_pretty(&credentials).unwrap();
|
||||
let parsed: TomlCredentials = toml::from_str(&toml_str).unwrap();
|
||||
|
||||
assert_eq!(parsed.credentials.len(), 2);
|
||||
assert_eq!(
|
||||
parsed.credentials[0].service.to_string(),
|
||||
"https://example.com/"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.credentials[1].service.to_string(),
|
||||
"https://test.org/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_store_operations() {
|
||||
let mut store = TextCredentialStore::default();
|
||||
let credentials = Credentials::basic(Some("user".to_string()), Some("pass".to_string()));
|
||||
|
||||
let service = Service::from_str("https://example.com").unwrap();
|
||||
store.insert(service.clone(), credentials.clone());
|
||||
let url = Url::parse("https://example.com/").unwrap();
|
||||
assert!(store.get_credentials(&url).is_some());
|
||||
|
||||
let url = Url::parse("https://example.com/path").unwrap();
|
||||
let retrieved = store.get_credentials(&url).unwrap();
|
||||
assert_eq!(retrieved.username(), Some("user"));
|
||||
assert_eq!(retrieved.password(), Some("pass"));
|
||||
|
||||
assert!(store.remove(&service).is_some());
|
||||
let url = Url::parse("https://example.com/").unwrap();
|
||||
assert!(store.get_credentials(&url).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_operations() {
|
||||
let mut temp_file = NamedTempFile::new().unwrap();
|
||||
writeln!(
|
||||
temp_file,
|
||||
r#"
|
||||
[[credential]]
|
||||
service = "https://example.com"
|
||||
username = "testuser"
|
||||
scheme = "basic"
|
||||
password = "testpass"
|
||||
|
||||
[[credential]]
|
||||
service = "https://test.org"
|
||||
username = "user2"
|
||||
password = "pass2"
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let store = TextCredentialStore::from_file(temp_file.path()).unwrap();
|
||||
|
||||
let url = Url::parse("https://example.com/").unwrap();
|
||||
assert!(store.get_credentials(&url).is_some());
|
||||
let url = Url::parse("https://test.org/").unwrap();
|
||||
assert!(store.get_credentials(&url).is_some());
|
||||
|
||||
let url = Url::parse("https://example.com").unwrap();
|
||||
let cred = store.get_credentials(&url).unwrap();
|
||||
assert_eq!(cred.username(), Some("testuser"));
|
||||
assert_eq!(cred.password(), Some("testpass"));
|
||||
|
||||
// Test saving
|
||||
let temp_output = NamedTempFile::new().unwrap();
|
||||
store.write(temp_output.path()).unwrap();
|
||||
|
||||
let content = fs::read_to_string(temp_output.path()).unwrap();
|
||||
assert!(content.contains("example.com"));
|
||||
assert!(content.contains("testuser"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prefix_matching() {
|
||||
let mut store = TextCredentialStore::default();
|
||||
let credentials = Credentials::basic(Some("user".to_string()), Some("pass".to_string()));
|
||||
|
||||
// Store credentials for a specific path prefix
|
||||
let service = Service::from_str("https://example.com/api").unwrap();
|
||||
store.insert(service.clone(), credentials.clone());
|
||||
|
||||
// Should match URLs that are prefixes of the stored service
|
||||
let matching_urls = [
|
||||
"https://example.com/api",
|
||||
"https://example.com/api/v1",
|
||||
"https://example.com/api/v1/users",
|
||||
];
|
||||
|
||||
for url_str in matching_urls {
|
||||
let url = Url::parse(url_str).unwrap();
|
||||
let cred = store.get_credentials(&url);
|
||||
assert!(cred.is_some(), "Failed to match URL with prefix: {url_str}");
|
||||
}
|
||||
|
||||
// Should NOT match URLs that are not prefixes
|
||||
let non_matching_urls = [
|
||||
"https://example.com/different",
|
||||
"https://example.com/ap", // Not a complete path segment match
|
||||
"https://example.com", // Shorter than the stored prefix
|
||||
];
|
||||
|
||||
for url_str in non_matching_urls {
|
||||
let url = Url::parse(url_str).unwrap();
|
||||
let cred = store.get_credentials(&url);
|
||||
assert!(cred.is_none(), "Should not match non-prefix URL: {url_str}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_realm_based_matching() {
|
||||
let mut store = TextCredentialStore::default();
|
||||
let credentials = Credentials::basic(Some("user".to_string()), Some("pass".to_string()));
|
||||
|
||||
// Store by full URL (realm)
|
||||
let service = Service::from_str("https://example.com").unwrap();
|
||||
store.insert(service.clone(), credentials.clone());
|
||||
|
||||
// Should match URLs in the same realm
|
||||
let matching_urls = [
|
||||
"https://example.com",
|
||||
"https://example.com/path",
|
||||
"https://example.com/different/path",
|
||||
"https://example.com:443/path", // Default HTTPS port
|
||||
];
|
||||
|
||||
for url_str in matching_urls {
|
||||
let url = Url::parse(url_str).unwrap();
|
||||
let cred = store.get_credentials(&url);
|
||||
assert!(
|
||||
cred.is_some(),
|
||||
"Failed to match URL in same realm: {url_str}"
|
||||
);
|
||||
}
|
||||
|
||||
// Should NOT match URLs in different realms
|
||||
let non_matching_urls = [
|
||||
"http://example.com", // Different scheme
|
||||
"https://different.com", // Different host
|
||||
"https://example.com:8080", // Different port
|
||||
];
|
||||
|
||||
for url_str in non_matching_urls {
|
||||
let url = Url::parse(url_str).unwrap();
|
||||
let cred = store.get_credentials(&url);
|
||||
assert!(
|
||||
cred.is_none(),
|
||||
"Should not match URL in different realm: {url_str}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_most_specific_prefix_matching() {
|
||||
let mut store = TextCredentialStore::default();
|
||||
let general_cred =
|
||||
Credentials::basic(Some("general".to_string()), Some("pass1".to_string()));
|
||||
let specific_cred =
|
||||
Credentials::basic(Some("specific".to_string()), Some("pass2".to_string()));
|
||||
|
||||
// Store credentials with different prefix lengths
|
||||
let general_service = Service::from_str("https://example.com/api").unwrap();
|
||||
let specific_service = Service::from_str("https://example.com/api/v1").unwrap();
|
||||
store.insert(general_service.clone(), general_cred);
|
||||
store.insert(specific_service.clone(), specific_cred);
|
||||
|
||||
// Should match the most specific prefix
|
||||
let url = Url::parse("https://example.com/api/v1/users").unwrap();
|
||||
let cred = store.get_credentials(&url).unwrap();
|
||||
assert_eq!(cred.username(), Some("specific"));
|
||||
|
||||
// Should match the general prefix for non-specific paths
|
||||
let url = Url::parse("https://example.com/api/v2").unwrap();
|
||||
let cred = store.get_credentials(&url).unwrap();
|
||||
assert_eq!(cred.username(), Some("general"));
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ doctest = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
uv-auth = { workspace = true }
|
||||
uv-cache = { workspace = true, features = ["clap"] }
|
||||
uv-configuration = { workspace = true, features = ["clap"] }
|
||||
uv-distribution-types = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -8,10 +8,11 @@ use clap::builder::Styles;
|
|||
use clap::builder::styling::{AnsiColor, Effects, Style};
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
|
||||
use uv_auth::Service;
|
||||
use uv_cache::CacheArgs;
|
||||
use uv_configuration::{
|
||||
ExportFormat, IndexStrategy, KeyringProviderType, PackageNameSpecifier, ProjectBuildBackend,
|
||||
Service, TargetTriple, TrustedHost, TrustedPublishing, VersionControlSystem,
|
||||
TargetTriple, TrustedHost, TrustedPublishing, VersionControlSystem,
|
||||
};
|
||||
use uv_distribution_types::{
|
||||
ConfigSettingEntry, ConfigSettingPackageEntry, Index, IndexUrl, Origin, PipExtraIndex,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ uv-pep440 = { workspace = true }
|
|||
uv-pep508 = { workspace = true, features = ["schemars"] }
|
||||
uv-platform-tags = { workspace = true }
|
||||
uv-preview = { workspace = true }
|
||||
uv-redacted = { workspace = true }
|
||||
uv-static = { workspace = true }
|
||||
uv-warnings = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"], optional = true }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
use std::str::FromStr;
|
||||
use uv_auth::{self, KeyringProvider};
|
||||
use uv_preview::{Preview, PreviewFeatures};
|
||||
use uv_redacted::DisplaySafeUrl;
|
||||
use uv_warnings::warn_user_once;
|
||||
|
||||
/// Keyring provider type to use for credential lookup.
|
||||
|
|
@ -51,60 +49,3 @@ impl std::fmt::Display for KeyringProviderType {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ServiceParseError {
|
||||
#[error("failed to parse URL: {0}")]
|
||||
InvalidUrl(#[from] url::ParseError),
|
||||
#[error("only HTTPS is supported")]
|
||||
UnsupportedScheme,
|
||||
}
|
||||
|
||||
/// A service URL that wraps [`DisplaySafeUrl`] for CLI usage.
|
||||
///
|
||||
/// This type provides automatic URL parsing and validation when used as a CLI argument,
|
||||
/// eliminating the need for manual parsing in command functions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Service(DisplaySafeUrl);
|
||||
|
||||
impl Service {
|
||||
/// Get the underlying [`DisplaySafeUrl`].
|
||||
pub fn url(&self) -> &DisplaySafeUrl {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Convert into the underlying [`DisplaySafeUrl`].
|
||||
pub fn into_url(self) -> DisplaySafeUrl {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Service {
|
||||
type Err = ServiceParseError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
// First try parsing as-is
|
||||
let url = match DisplaySafeUrl::parse(s) {
|
||||
Ok(url) => url,
|
||||
Err(url::ParseError::RelativeUrlWithoutBase) => {
|
||||
// If it's a relative URL, try prepending https://
|
||||
let with_https = format!("https://{s}");
|
||||
DisplaySafeUrl::parse(&with_https)?
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
|
||||
// Only allow HTTPS URLs
|
||||
if url.scheme() != "https" {
|
||||
return Err(ServiceParseError::UnsupportedScheme);
|
||||
}
|
||||
|
||||
Ok(Self(url))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Service {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,8 @@ pub enum StateBucket {
|
|||
ManagedPython,
|
||||
/// Installed tools.
|
||||
Tools,
|
||||
/// Stored authentication credentials.
|
||||
Credentials,
|
||||
}
|
||||
|
||||
impl StateBucket {
|
||||
|
|
@ -112,6 +114,7 @@ impl StateBucket {
|
|||
match self {
|
||||
Self::ManagedPython => "python",
|
||||
Self::Tools => "tools",
|
||||
Self::Credentials => "credentials",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ use anyhow::{Result, bail};
|
|||
use console::Term;
|
||||
use owo_colors::OwoColorize;
|
||||
|
||||
use uv_auth::Credentials;
|
||||
use uv_configuration::{KeyringProviderType, Service};
|
||||
use uv_auth::Service;
|
||||
use uv_auth::{Credentials, TextCredentialStore};
|
||||
use uv_configuration::KeyringProviderType;
|
||||
use uv_preview::Preview;
|
||||
|
||||
use crate::commands::auth::AuthBackend;
|
||||
use crate::{commands::ExitStatus, printer::Printer};
|
||||
|
||||
/// Login to a service.
|
||||
|
|
@ -21,23 +23,7 @@ pub(crate) async fn login(
|
|||
preview: Preview,
|
||||
) -> Result<ExitStatus> {
|
||||
let url = service.url();
|
||||
|
||||
// Be helpful about incompatible `keyring-provider` settings
|
||||
let Some(keyring_provider) = &keyring_provider else {
|
||||
bail!(
|
||||
"Logging in requires setting `keyring-provider = {}` for credentials to be retrieved in subsequent commands",
|
||||
KeyringProviderType::Native
|
||||
);
|
||||
};
|
||||
let provider = match keyring_provider {
|
||||
KeyringProviderType::Native => keyring_provider.to_provider(&preview).unwrap(),
|
||||
KeyringProviderType::Disabled | KeyringProviderType::Subprocess => {
|
||||
bail!(
|
||||
"Cannot login with `keyring-provider = {keyring_provider}`, use `keyring-provider = {}` instead",
|
||||
KeyringProviderType::Native
|
||||
);
|
||||
}
|
||||
};
|
||||
let backend = AuthBackend::from_settings(keyring_provider.as_ref(), preview)?;
|
||||
|
||||
// Extract credentials from URL if present
|
||||
let url_credentials = Credentials::from_url(url);
|
||||
|
|
@ -116,7 +102,15 @@ pub(crate) async fn login(
|
|||
|
||||
// TODO(zanieb): Add support for other authentication schemes here, e.g., `Credentials::Bearer`
|
||||
let credentials = Credentials::basic(Some(username), Some(password));
|
||||
provider.store(url, &credentials).await?;
|
||||
match backend {
|
||||
AuthBackend::Keyring(provider) => {
|
||||
provider.store(url, &credentials).await?;
|
||||
}
|
||||
AuthBackend::TextStore(mut text_store) => {
|
||||
text_store.insert(service.clone(), credentials);
|
||||
text_store.write(TextCredentialStore::default_file()?)?;
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(
|
||||
printer.stderr(),
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ use std::fmt::Write;
|
|||
use anyhow::{Context, Result, bail};
|
||||
use owo_colors::OwoColorize;
|
||||
|
||||
use uv_auth::Credentials;
|
||||
use uv_configuration::{KeyringProviderType, Service};
|
||||
use uv_auth::Service;
|
||||
use uv_auth::{Credentials, TextCredentialStore};
|
||||
use uv_configuration::KeyringProviderType;
|
||||
use uv_preview::Preview;
|
||||
|
||||
use crate::commands::auth::AuthBackend;
|
||||
use crate::{commands::ExitStatus, printer::Printer};
|
||||
|
||||
/// Logout from a service.
|
||||
|
|
@ -20,6 +22,7 @@ pub(crate) async fn logout(
|
|||
preview: Preview,
|
||||
) -> Result<ExitStatus> {
|
||||
let url = service.url();
|
||||
let backend = AuthBackend::from_settings(keyring_provider.as_ref(), preview)?;
|
||||
|
||||
// Extract credentials from URL if present
|
||||
let url_credentials = Credentials::from_url(url);
|
||||
|
|
@ -42,25 +45,23 @@ pub(crate) async fn logout(
|
|||
format!("{username}@{}", url.without_credentials())
|
||||
};
|
||||
|
||||
// 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.
|
||||
let keyring_provider = keyring_provider.unwrap_or(KeyringProviderType::Native);
|
||||
|
||||
// Be helpful about incompatible `keyring-provider` settings
|
||||
let provider = match keyring_provider {
|
||||
KeyringProviderType::Native => keyring_provider.to_provider(&preview).unwrap(),
|
||||
KeyringProviderType::Disabled | KeyringProviderType::Subprocess => {
|
||||
bail!(
|
||||
"Cannot logout with `keyring-provider = {keyring_provider}`, use `keyring-provider = {}` instead",
|
||||
KeyringProviderType::Native
|
||||
);
|
||||
// TODO(zanieb): Consider exhaustively logging out from all backends
|
||||
match backend {
|
||||
AuthBackend::Keyring(provider) => {
|
||||
provider
|
||||
.remove(url, &username)
|
||||
.await
|
||||
.with_context(|| format!("Unable to remove credentials for {display_url}"))?;
|
||||
}
|
||||
};
|
||||
|
||||
provider
|
||||
.remove(url, &username)
|
||||
.await
|
||||
.with_context(|| format!("Unable to remove credentials for {display_url}"))?;
|
||||
AuthBackend::TextStore(mut text_store) => {
|
||||
if text_store.remove(&service).is_none() {
|
||||
bail!("No matching entry found for {display_url}");
|
||||
}
|
||||
text_store
|
||||
.write(TextCredentialStore::default_file()?)
|
||||
.with_context(|| "Failed to persist changes to credentials after removal")?;
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(
|
||||
printer.stderr(),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,37 @@
|
|||
use uv_auth::{KeyringProvider, TextCredentialStore, TomlCredentialError};
|
||||
use uv_configuration::KeyringProviderType;
|
||||
use uv_preview::Preview;
|
||||
|
||||
pub(crate) mod login;
|
||||
pub(crate) mod logout;
|
||||
pub(crate) mod token;
|
||||
|
||||
/// The storage backend to use in `uv auth` commands.
|
||||
enum AuthBackend {
|
||||
Keyring(KeyringProvider),
|
||||
TextStore(TextCredentialStore),
|
||||
}
|
||||
|
||||
impl AuthBackend {
|
||||
fn from_settings(
|
||||
keyring: Option<&KeyringProviderType>,
|
||||
preview: Preview,
|
||||
) -> Result<Self, TomlCredentialError> {
|
||||
// For keyring providers, we only support persistence via the native keyring right now
|
||||
if let Some(keyring) = match keyring {
|
||||
Some(provider @ KeyringProviderType::Native) => provider.to_provider(&preview),
|
||||
_ => None,
|
||||
} {
|
||||
return Ok(Self::Keyring(keyring));
|
||||
}
|
||||
|
||||
// Otherwise, we'll use the plain text credential store
|
||||
match TextCredentialStore::from_file(TextCredentialStore::default_file()?) {
|
||||
Ok(store) => Ok(Self::TextStore(store)),
|
||||
Err(TomlCredentialError::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
Ok(Self::TextStore(TextCredentialStore::default()))
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
use std::fmt::Write;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
use uv_auth::Credentials;
|
||||
use uv_configuration::{KeyringProviderType, Service};
|
||||
use uv_auth::Service;
|
||||
use uv_configuration::KeyringProviderType;
|
||||
use uv_preview::Preview;
|
||||
|
||||
use crate::commands::auth::AuthBackend;
|
||||
use crate::{commands::ExitStatus, printer::Printer};
|
||||
|
||||
/// Show the token that will be used for a service.
|
||||
|
|
@ -17,13 +19,7 @@ pub(crate) async fn token(
|
|||
preview: Preview,
|
||||
) -> Result<ExitStatus> {
|
||||
let url = service.url();
|
||||
// Determine the keyring provider to use
|
||||
let Some(keyring_provider) = &keyring_provider else {
|
||||
bail!("Retrieving credentials requires setting a `keyring-provider`");
|
||||
};
|
||||
let Some(provider) = keyring_provider.to_provider(&preview) else {
|
||||
bail!("Cannot retrieve credentials with `keyring-provider = {keyring_provider}`");
|
||||
};
|
||||
let backend = AuthBackend::from_settings(keyring_provider.as_ref(), preview)?;
|
||||
|
||||
// Extract credentials from URL if present
|
||||
let url_credentials = Credentials::from_url(url);
|
||||
|
|
@ -46,10 +42,16 @@ pub(crate) async fn token(
|
|||
format!("{username}@{}", url.without_credentials())
|
||||
};
|
||||
|
||||
let credentials = provider
|
||||
.fetch(url, Some(&username))
|
||||
.await
|
||||
.with_context(|| format!("Failed to fetch credentials for {display_url}"))?;
|
||||
let credentials = match &backend {
|
||||
AuthBackend::Keyring(provider) => provider
|
||||
.fetch(url, Some(&username))
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to fetch credentials for {display_url}"))?,
|
||||
AuthBackend::TextStore(text_store) => text_store
|
||||
.get_credentials(url)
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to fetch credentials for {display_url}"))?,
|
||||
};
|
||||
|
||||
let Some(password) = credentials.password() else {
|
||||
bail!(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use std::path::PathBuf;
|
|||
use std::process;
|
||||
use std::str::FromStr;
|
||||
|
||||
use uv_auth::Service;
|
||||
use uv_cache::{CacheArgs, Refresh};
|
||||
use uv_cli::comma::CommaSeparatedRequirements;
|
||||
use uv_cli::{
|
||||
|
|
@ -25,8 +26,7 @@ use uv_configuration::{
|
|||
BuildIsolation, BuildOptions, Concurrency, DependencyGroups, DryRun, EditableMode,
|
||||
ExportFormat, ExtrasSpecification, HashCheckingMode, IndexStrategy, InstallOptions,
|
||||
KeyringProviderType, NoBinary, NoBuild, ProjectBuildBackend, Reinstall, RequiredVersion,
|
||||
Service, SourceStrategy, TargetTriple, TrustedHost, TrustedPublishing, Upgrade,
|
||||
VersionControlSystem,
|
||||
SourceStrategy, TargetTriple, TrustedHost, TrustedPublishing, Upgrade, VersionControlSystem,
|
||||
};
|
||||
use uv_distribution_types::{
|
||||
ConfigSettings, DependencyMetadata, ExtraBuildVariables, Index, IndexLocations, IndexUrl,
|
||||
|
|
@ -3506,6 +3506,8 @@ impl AuthLogoutSettings {
|
|||
keyring_provider, ..
|
||||
} = top_level;
|
||||
|
||||
let keyring_provider = args.keyring_provider.combine(keyring_provider);
|
||||
|
||||
Self {
|
||||
service: args.service,
|
||||
username: args.username,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
#[cfg(feature = "keyring-tests")]
|
||||
use anyhow::Result;
|
||||
use assert_cmd::assert::OutputAssertExt;
|
||||
#[cfg(feature = "keyring-tests")]
|
||||
use assert_fs::{fixture::PathChild, prelude::FileWriteStr};
|
||||
use uv_static::EnvVars;
|
||||
|
||||
use crate::common::{TestContext, uv_snapshot, venv_bin_path};
|
||||
use crate::common::venv_bin_path;
|
||||
use crate::common::{TestContext, uv_snapshot};
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "keyring-tests")]
|
||||
fn add_package_native_keyring() -> Result<()> {
|
||||
let context = TestContext::new("3.12").with_real_home();
|
||||
|
||||
|
|
@ -15,6 +19,8 @@ fn add_package_native_keyring() -> Result<()> {
|
|||
.arg("https://pypi-proxy.fly.dev/basic-auth/simple")
|
||||
.arg("--username")
|
||||
.arg("public")
|
||||
.arg("--keyring-provider")
|
||||
.arg("native")
|
||||
.status()?;
|
||||
|
||||
// Configure `pyproject.toml` with native keyring provider.
|
||||
|
|
@ -117,6 +123,7 @@ fn add_package_native_keyring() -> Result<()> {
|
|||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "keyring-tests")]
|
||||
fn token_native_keyring() -> Result<()> {
|
||||
let context = TestContext::new_with_versions(&[]).with_real_home();
|
||||
|
||||
|
|
@ -126,34 +133,10 @@ fn token_native_keyring() -> Result<()> {
|
|||
.arg("https://pypi-proxy.fly.dev/basic-auth/simple")
|
||||
.arg("--username")
|
||||
.arg("public")
|
||||
.arg("--keyring-provider")
|
||||
.arg("native")
|
||||
.status()?;
|
||||
|
||||
// Without a service name
|
||||
uv_snapshot!(context.auth_token(), @r"
|
||||
success: false
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
error: the following required arguments were not provided:
|
||||
<SERVICE>
|
||||
|
||||
Usage: uv auth token --cache-dir [CACHE_DIR] <SERVICE>
|
||||
|
||||
For more information, try '--help'.
|
||||
");
|
||||
|
||||
// Without a keyring provider...
|
||||
uv_snapshot!(context.auth_token()
|
||||
.arg("https://pypi-proxy.fly.dev/basic-auth/simple"), @r"
|
||||
success: false
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
error: Retrieving credentials requires setting a `keyring-provider`
|
||||
");
|
||||
|
||||
// Without persisted credentials
|
||||
uv_snapshot!(context.auth_token()
|
||||
.arg("https://pypi-proxy.fly.dev/basic-auth/simple")
|
||||
|
|
@ -364,8 +347,6 @@ fn token_subprocess_keyring() {
|
|||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
Keyring request for public@https://public@pypi-proxy.fly.dev/basic-auth/simple
|
||||
Keyring request for public@pypi-proxy.fly.dev
|
||||
error: Failed to fetch credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple
|
||||
"
|
||||
);
|
||||
|
|
@ -378,14 +359,12 @@ fn token_subprocess_keyring() {
|
|||
.arg("subprocess")
|
||||
.env(EnvVars::KEYRING_TEST_CREDENTIALS, r#"{"pypi-proxy.fly.dev": {"public": "heron"}}"#)
|
||||
.env(EnvVars::PATH, venv_bin_path(&context.venv)), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
success: false
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
heron
|
||||
|
||||
----- stderr -----
|
||||
Keyring request for public@https://public@pypi-proxy.fly.dev/basic-auth/simple
|
||||
Keyring request for public@pypi-proxy.fly.dev
|
||||
error: Failed to fetch credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple
|
||||
"
|
||||
);
|
||||
|
||||
|
|
@ -409,6 +388,7 @@ fn token_subprocess_keyring() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "keyring-tests")]
|
||||
fn login_native_keyring() -> Result<()> {
|
||||
let context = TestContext::new_with_versions(&[]).with_real_home();
|
||||
|
||||
|
|
@ -418,6 +398,8 @@ fn login_native_keyring() -> Result<()> {
|
|||
.arg("https://pypi-proxy.fly.dev/basic-auth/simple")
|
||||
.arg("--username")
|
||||
.arg("public")
|
||||
.arg("--keyring-provider")
|
||||
.arg("native")
|
||||
.status()?;
|
||||
|
||||
// Without a service name
|
||||
|
|
@ -465,19 +447,6 @@ fn login_native_keyring() -> Result<()> {
|
|||
error: No password provided; did you mean to provide `--password` or `--token`?
|
||||
");
|
||||
|
||||
// Without a keyring provider
|
||||
uv_snapshot!(context.auth_login()
|
||||
.arg("https://pypi-proxy.fly.dev/basic-auth/simple")
|
||||
.arg("--token")
|
||||
.arg("foo"), @r"
|
||||
success: false
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
error: Logging in requires setting `keyring-provider = native` for credentials to be retrieved in subsequent commands
|
||||
");
|
||||
|
||||
// Successful
|
||||
uv_snapshot!(context.auth_login()
|
||||
.arg("https://pypi-proxy.fly.dev/basic-auth/simple")
|
||||
|
|
@ -501,6 +470,7 @@ fn login_native_keyring() -> Result<()> {
|
|||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "keyring-tests")]
|
||||
fn login_token_native_keyring() -> Result<()> {
|
||||
let context = TestContext::new_with_versions(&[]).with_real_home();
|
||||
|
||||
|
|
@ -510,6 +480,8 @@ fn login_token_native_keyring() -> Result<()> {
|
|||
.arg("https://pypi-proxy.fly.dev/basic-auth/simple")
|
||||
.arg("--username")
|
||||
.arg("__token__")
|
||||
.arg("--keyring-provider")
|
||||
.arg("native")
|
||||
.status()?;
|
||||
|
||||
// Successful with token
|
||||
|
|
@ -533,6 +505,7 @@ fn login_token_native_keyring() -> Result<()> {
|
|||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "keyring-tests")]
|
||||
fn logout_native_keyring() -> Result<()> {
|
||||
let context = TestContext::new_with_versions(&[]).with_real_home();
|
||||
|
||||
|
|
@ -542,6 +515,8 @@ fn logout_native_keyring() -> Result<()> {
|
|||
.arg("https://pypi-proxy.fly.dev/basic-auth/simple")
|
||||
.arg("--username")
|
||||
.arg("public")
|
||||
.arg("--keyring-provider")
|
||||
.arg("native")
|
||||
.status()?;
|
||||
|
||||
// Without a service name
|
||||
|
|
@ -559,9 +534,11 @@ fn logout_native_keyring() -> Result<()> {
|
|||
For more information, try '--help'.
|
||||
");
|
||||
|
||||
// Logout without a keyring provider
|
||||
// Logout before logging in
|
||||
uv_snapshot!(context.auth_logout()
|
||||
.arg("https://pypi-proxy.fly.dev/basic-auth/simple"), @r"
|
||||
.arg("https://pypi-proxy.fly.dev/basic-auth/simple")
|
||||
.arg("--keyring-provider")
|
||||
.arg("native"), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
|
|
@ -571,21 +548,6 @@ fn logout_native_keyring() -> Result<()> {
|
|||
Removed credentials for https://pypi-proxy.fly.dev/basic-auth/simple
|
||||
");
|
||||
|
||||
// Logout before logging in (without a username)
|
||||
uv_snapshot!(context.auth_logout()
|
||||
.arg("https://pypi-proxy.fly.dev/basic-auth/simple")
|
||||
.arg("--keyring-provider")
|
||||
.arg("native"), @r"
|
||||
success: false
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning.
|
||||
error: Unable to remove credentials for https://pypi-proxy.fly.dev/basic-auth/simple
|
||||
Caused by: No matching entry found in secure storage
|
||||
");
|
||||
|
||||
// Logout before logging in (with a username)
|
||||
uv_snapshot!(context.auth_logout()
|
||||
.arg("https://pypi-proxy.fly.dev/basic-auth/simple")
|
||||
|
|
@ -693,6 +655,7 @@ fn logout_native_keyring() -> Result<()> {
|
|||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning.
|
||||
error: Cannot specify a username both via the URL and CLI; found `--username foo` and `public`
|
||||
");
|
||||
|
||||
|
|
@ -716,6 +679,7 @@ fn logout_native_keyring() -> Result<()> {
|
|||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "keyring-tests")]
|
||||
fn logout_token_native_keyring() -> Result<()> {
|
||||
let context = TestContext::new_with_versions(&[]).with_real_home();
|
||||
|
||||
|
|
@ -723,6 +687,8 @@ fn logout_token_native_keyring() -> Result<()> {
|
|||
context
|
||||
.auth_logout()
|
||||
.arg("https://pypi-proxy.fly.dev/basic-auth/simple")
|
||||
.arg("--keyring-provider")
|
||||
.arg("native")
|
||||
.status()?;
|
||||
|
||||
// Login with a token
|
||||
|
|
@ -760,6 +726,7 @@ fn logout_token_native_keyring() -> Result<()> {
|
|||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "keyring-tests")]
|
||||
fn login_native_keyring_url() {
|
||||
let context = TestContext::new_with_versions(&[]).with_real_home();
|
||||
|
||||
|
|
@ -934,3 +901,178 @@ fn login_native_keyring_url() {
|
|||
error: When using `--token`, a username cannot not be provided; found: test
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_text_store() {
|
||||
let context = TestContext::new_with_versions(&[]);
|
||||
|
||||
// Successful login without keyring provider (uses text store)
|
||||
uv_snapshot!(context.auth_login()
|
||||
.arg("https://pypi-proxy.fly.dev/basic-auth/simple")
|
||||
.arg("--username")
|
||||
.arg("public")
|
||||
.arg("--password")
|
||||
.arg("heron"), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
Stored credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple
|
||||
"
|
||||
);
|
||||
|
||||
// Token-based login
|
||||
uv_snapshot!(context.auth_login()
|
||||
.arg("https://example.com/simple")
|
||||
.arg("--token")
|
||||
.arg("test-token"), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
Stored credentials for https://example.com/simple
|
||||
"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_text_store() {
|
||||
let context = TestContext::new_with_versions(&[]);
|
||||
|
||||
// Login first
|
||||
context
|
||||
.auth_login()
|
||||
.arg("https://pypi-proxy.fly.dev/basic-auth/simple")
|
||||
.arg("--username")
|
||||
.arg("public")
|
||||
.arg("--password")
|
||||
.arg("heron")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Retrieve the token
|
||||
uv_snapshot!(context.auth_token()
|
||||
.arg("https://pypi-proxy.fly.dev/basic-auth/simple")
|
||||
.arg("--username")
|
||||
.arg("public"), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
heron
|
||||
|
||||
----- stderr -----
|
||||
"
|
||||
);
|
||||
|
||||
// Login with token
|
||||
context
|
||||
.auth_login()
|
||||
.arg("https://example.com/simple")
|
||||
.arg("--token")
|
||||
.arg("test-token")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Retrieve token without username
|
||||
uv_snapshot!(context.auth_token()
|
||||
.arg("https://example.com/simple"), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
test-token
|
||||
|
||||
----- stderr -----
|
||||
"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logout_text_store() {
|
||||
let context = TestContext::new_with_versions(&[]);
|
||||
|
||||
// Login first
|
||||
context
|
||||
.auth_login()
|
||||
.arg("https://pypi-proxy.fly.dev/basic-auth/simple")
|
||||
.arg("--username")
|
||||
.arg("public")
|
||||
.arg("--password")
|
||||
.arg("heron")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Logout
|
||||
uv_snapshot!(context.auth_logout()
|
||||
.arg("https://pypi-proxy.fly.dev/basic-auth/simple")
|
||||
.arg("--username")
|
||||
.arg("public"), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
Removed credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple
|
||||
"
|
||||
);
|
||||
|
||||
// Login with token then logout
|
||||
context
|
||||
.auth_login()
|
||||
.arg("https://example.com/simple")
|
||||
.arg("--token")
|
||||
.arg("test-token")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
uv_snapshot!(context.auth_logout()
|
||||
.arg("https://example.com/simple"), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
Removed credentials for https://example.com/simple
|
||||
"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_disabled_provider_uses_text_store() {
|
||||
let context = TestContext::new_with_versions(&[]);
|
||||
|
||||
// Login with disabled provider should use text store
|
||||
uv_snapshot!(context.auth_login()
|
||||
.arg("https://pypi-proxy.fly.dev/basic-auth/simple")
|
||||
.arg("--username")
|
||||
.arg("public")
|
||||
.arg("--password")
|
||||
.arg("heron")
|
||||
.arg("--keyring-provider")
|
||||
.arg("disabled"), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
Stored credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple
|
||||
"
|
||||
);
|
||||
|
||||
// Token retrieval should work with disabled provider
|
||||
uv_snapshot!(context.auth_token()
|
||||
.arg("https://pypi-proxy.fly.dev/basic-auth/simple")
|
||||
.arg("--username")
|
||||
.arg("public")
|
||||
.arg("--keyring-provider")
|
||||
.arg("disabled"), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
heron
|
||||
|
||||
----- stderr -----
|
||||
"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -854,6 +854,10 @@ impl TestContext {
|
|||
.env(EnvVars::HOME, self.home_dir.as_os_str())
|
||||
.env(EnvVars::APPDATA, self.home_dir.as_os_str())
|
||||
.env(EnvVars::USERPROFILE, self.home_dir.as_os_str())
|
||||
.env(
|
||||
EnvVars::XDG_DATA_HOME,
|
||||
self.home_dir.join("data").as_os_str(),
|
||||
)
|
||||
.env(EnvVars::UV_PYTHON_INSTALL_DIR, "")
|
||||
// Installations are not allowed by default; see `Self::with_managed_python_dirs`
|
||||
.env(EnvVars::UV_PYTHON_DOWNLOADS, "never")
|
||||
|
|
@ -868,7 +872,6 @@ impl TestContext {
|
|||
.env_remove(EnvVars::UV_CACHE_DIR)
|
||||
.env_remove(EnvVars::UV_TOOL_BIN_DIR)
|
||||
.env_remove(EnvVars::XDG_CONFIG_HOME)
|
||||
.env_remove(EnvVars::XDG_DATA_HOME)
|
||||
// I believe the intent of all tests is that they are run outside the
|
||||
// context of an existing git repository. And when they aren't, state
|
||||
// from the parent git repository can bleed into the behavior of `uv
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
|
||||
pub(crate) mod common;
|
||||
|
||||
#[cfg(feature = "keyring-tests")]
|
||||
mod auth;
|
||||
|
||||
mod branching_urls;
|
||||
|
|
|
|||
Loading…
Reference in New Issue