Add an optional authentication policy to [index] configuration (#11896)

Adds a new optional key `auth-policy` to `[tool.uv.index]` that sets the
authentication policy for the index URL.

The default is `"auto"`, which attempts to authenticate when necessary.
`"always"` always attempts to authenticate and fails if the endpoint is
unauthenticated. `"never"` never attempts to authenticate.

These policy address two kinds of cases:
* Some indexes don’t fail on unauthenticated requests; instead they just
forward to the public PyPI. This can leave the user confused as to why
their package is missing. The "always" policy prevents this.
* "never" allows users to ensure their credentials couldn't be leaked to
an unexpected index, though it will only allow for successful requests
on an index that doesn't require credentials.

Closes #11600
This commit is contained in:
John Mumm 2025-03-10 18:24:25 +01:00 committed by GitHub
parent 44c3648537
commit c58675fdac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 797 additions and 139 deletions

2
Cargo.lock generated
View File

@ -4612,6 +4612,8 @@ dependencies = [
"reqwest-middleware",
"rust-netrc",
"rustc-hash",
"schemars",
"serde",
"tempfile",
"test-log",
"tokio",

View File

@ -24,6 +24,8 @@ reqwest = { workspace = true }
reqwest-middleware = { workspace = true }
rust-netrc = { workspace = true }
rustc-hash = { workspace = true }
schemars = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] }
tokio = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }

View File

@ -28,15 +28,7 @@ impl Username {
/// Unlike `reqwest`, empty usernames are be encoded as `None` instead of an empty string.
pub(crate) fn new(value: Option<String>) -> Self {
// Ensure empty strings are `None`
if let Some(value) = value {
if value.is_empty() {
Self(None)
} else {
Self(Some(value))
}
} else {
Self(value)
}
Self(value.filter(|s| !s.is_empty()))
}
pub(crate) fn none() -> Self {

View File

@ -7,12 +7,14 @@ use cache::CredentialsCache;
pub use credentials::Credentials;
pub use keyring::KeyringProvider;
pub use middleware::AuthMiddleware;
pub use policy::{AuthPolicy, UrlAuthPolicies};
use realm::Realm;
mod cache;
mod credentials;
mod keyring;
mod middleware;
mod policy;
mod realm;
// TODO(zanieb): Consider passing a cache explicitly throughout

View File

@ -5,6 +5,7 @@ use url::Url;
use crate::{
credentials::{Credentials, Username},
policy::{AuthPolicy, UrlAuthPolicies},
realm::Realm,
CredentialsCache, KeyringProvider, CREDENTIALS_CACHE,
};
@ -56,8 +57,10 @@ pub struct AuthMiddleware {
netrc: NetrcMode,
keyring: Option<KeyringProvider>,
cache: Option<CredentialsCache>,
/// We know that the endpoint needs authentication, so we don't try to send an unauthenticated
/// request, avoiding cloning an uncloneable request.
/// Auth policies for specific URLs.
url_auth_policies: UrlAuthPolicies,
/// Set all endpoints as needing authentication. We never try to send an
/// unauthenticated request, avoiding cloning an uncloneable request.
only_authenticated: bool,
}
@ -67,6 +70,7 @@ impl AuthMiddleware {
netrc: NetrcMode::default(),
keyring: None,
cache: None,
url_auth_policies: UrlAuthPolicies::new(),
only_authenticated: false,
}
}
@ -98,8 +102,15 @@ impl AuthMiddleware {
self
}
/// We know that the endpoint needs authentication, so we don't try to send an unauthenticated
/// request, avoiding cloning an uncloneable request.
/// Configure the [`AuthPolicy`]s to use for URLs.
#[must_use]
pub fn with_url_auth_policies(mut self, auth_policies: UrlAuthPolicies) -> Self {
self.url_auth_policies = auth_policies;
self
}
/// Set all endpoints as needing authentication. We never try to send an
/// unauthenticated request, avoiding cloning an uncloneable request.
#[must_use]
pub fn with_only_authenticated(mut self, only_authenticated: bool) -> Self {
self.only_authenticated = only_authenticated;
@ -165,84 +176,58 @@ impl Middleware for AuthMiddleware {
next: Next<'_>,
) -> reqwest_middleware::Result<Response> {
// Check for credentials attached to the request already
let credentials = Credentials::from_request(&request);
let request_credentials = Credentials::from_request(&request);
// In the middleware, existing credentials are already moved from the URL
// to the headers so for display purposes we restore some information
let url = tracing_url(&request, credentials.as_ref());
let url = tracing_url(&request, request_credentials.as_ref());
trace!("Handling request for {url}");
if let Some(credentials) = credentials {
let credentials = Arc::new(credentials);
let auth_policy = self.url_auth_policies.policy_for(request.url());
// If there's a password, send the request and cache
if credentials.password().is_some() {
trace!("Request for {url} is already fully authenticated");
let credentials: Option<Arc<Credentials>> = if matches!(auth_policy, AuthPolicy::Never) {
None
} else {
if let Some(request_credentials) = request_credentials {
return self
.complete_request(Some(credentials), request, extensions, next)
.complete_request_with_request_credentials(
request_credentials,
request,
extensions,
next,
&url,
)
.await;
}
trace!("Request for {url} is missing a password, looking for credentials");
// There's just a username, try to find a password
let credentials = if let Some(credentials) = self
.cache()
.get_realm(Realm::from(request.url()), credentials.to_username())
{
// We have no credentials
trace!("Request for {url} is unauthenticated, checking cache");
// Check the cache for a URL match first. This can save us from
// making a failing request
let credentials = self.cache().get_url(request.url(), &Username::none());
if let Some(credentials) = credentials.as_ref() {
request = credentials.authenticate(request);
// Do not insert already-cached credentials
None
} else if let Some(credentials) = self
.cache()
.get_url(request.url(), credentials.as_username())
{
request = credentials.authenticate(request);
// Do not insert already-cached credentials
None
} else if let Some(credentials) = self
.fetch_credentials(Some(&credentials), request.url())
.await
{
request = credentials.authenticate(request);
Some(credentials)
} else {
// If we don't find a password, we'll still attempt the request with the existing credentials
Some(credentials)
};
return self
.complete_request(credentials, request, extensions, next)
.await;
}
// If it's fully authenticated, finish the request
if credentials.password().is_some() {
trace!("Request for {url} is fully authenticated");
return self.complete_request(None, request, extensions, next).await;
}
// We have no credentials
trace!("Request for {url} is unauthenticated, checking cache");
// Check the cache for a URL match first, this can save us from making a failing request
let credentials = self.cache().get_url(request.url(), &Username::none());
if let Some(credentials) = credentials.as_ref() {
request = credentials.authenticate(request);
// If it's fully authenticated, finish the request
if credentials.password().is_some() {
trace!("Request for {url} is fully authenticated");
return self.complete_request(None, request, extensions, next).await;
// If we just found a username, we'll make the request then look for password elsewhere
// if it fails
trace!("Found username for {url} in cache, attempting request");
}
// If we just found a username, we'll make the request then look for password elsewhere
// if it fails
trace!("Found username for {url} in cache, attempting request");
}
credentials
};
let attempt_has_username = credentials
.as_ref()
.is_some_and(|credentials| credentials.username().is_some());
let (mut retry_request, response) = if self.only_authenticated {
// For endpoints where we require the user to provide credentials, we don't try the
// unauthenticated request first.
trace!("Checking for credentials for {url}");
(request, None)
} else {
let retry_unauthenticated =
!self.only_authenticated && !matches!(auth_policy, AuthPolicy::Always);
let (mut retry_request, response) = if retry_unauthenticated {
let url = tracing_url(&request, credentials.as_deref());
if credentials.is_none() {
trace!("Attempting unauthenticated request for {url}");
@ -261,11 +246,13 @@ impl Middleware for AuthMiddleware {
let response = next.clone().run(request, extensions).await?;
// If we don't fail with authorization related codes, return the response
// If we don't fail with authorization related codes or
// authentication policy is Never, return the response.
if !matches!(
response.status(),
StatusCode::FORBIDDEN | StatusCode::NOT_FOUND | StatusCode::UNAUTHORIZED
) {
) || matches!(auth_policy, AuthPolicy::Never)
{
return Ok(response);
}
@ -276,6 +263,11 @@ impl Middleware for AuthMiddleware {
);
(retry_request, Some(response))
} else {
// For endpoints where we require the user to provide credentials, we don't try the
// unauthenticated request first.
trace!("Checking for credentials for {url}");
(request, None)
};
// Check if there are credentials in the realm-level cache
@ -363,6 +355,57 @@ impl AuthMiddleware {
result
}
/// Use known request credentials to complete the request.
async fn complete_request_with_request_credentials(
&self,
credentials: Credentials,
mut request: Request,
extensions: &mut Extensions,
next: Next<'_>,
url: &str,
) -> reqwest_middleware::Result<Response> {
let credentials = Arc::new(credentials);
// If there's a password, send the request and cache
if credentials.password().is_some() {
trace!("Request for {url} is already fully authenticated");
return self
.complete_request(Some(credentials), request, extensions, next)
.await;
}
trace!("Request for {url} is missing a password, looking for credentials");
// There's just a username, try to find a password
let credentials = if let Some(credentials) = self
.cache()
.get_realm(Realm::from(request.url()), credentials.to_username())
{
request = credentials.authenticate(request);
// Do not insert already-cached credentials
None
} else if let Some(credentials) = self
.cache()
.get_url(request.url(), credentials.as_username())
{
request = credentials.authenticate(request);
// Do not insert already-cached credentials
None
} else if let Some(credentials) = self
.fetch_credentials(Some(&credentials), request.url())
.await
{
request = credentials.authenticate(request);
Some(credentials)
} else {
// If we don't find a password, we'll still attempt the request with the existing credentials
Some(credentials)
};
return self
.complete_request(credentials, request, extensions, next)
.await;
}
/// Fetch credentials for a URL.
///
/// Supports netrc file and keyring lookups.
@ -1543,4 +1586,194 @@ mod tests {
Ok(())
}
fn auth_policies_for(url: &Url, policy: AuthPolicy) -> UrlAuthPolicies {
let mut url = url.clone();
let mut policies = UrlAuthPolicies::new();
url.set_password(None).ok();
url.set_username("").ok();
policies.add_policy(url, policy);
policies
}
/// With the "always" auth policy, requests should succeed on
/// authenticated requests with the correct credentials.
#[test(tokio::test)]
async fn test_auth_policy_always_with_credentials() -> Result<(), Error> {
let username = "user";
let password = "password";
let server = start_test_server(username, password).await;
let base_url = Url::parse(&server.uri())?;
let auth_policies = auth_policies_for(&base_url, AuthPolicy::Always);
let client = test_client_builder()
.with(
AuthMiddleware::new()
.with_cache(CredentialsCache::new())
.with_url_auth_policies(auth_policies),
)
.build();
Mock::given(method("GET"))
.and(path_regex("/*"))
.and(basic_auth(username, password))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let mut url = base_url.clone();
url.set_username(username).unwrap();
url.set_password(Some(password)).unwrap();
assert_eq!(client.get(url).send().await?.status(), 200);
assert_eq!(
client
.get(format!("{}/foo", server.uri()))
.send()
.await?
.status(),
200,
"Requests can be to different paths with index URL as prefix"
);
let mut url = base_url.clone();
url.set_username(username).unwrap();
url.set_password(Some("invalid")).unwrap();
assert_eq!(
client.get(url).send().await?.status(),
401,
"Incorrect credentials should fail"
);
Ok(())
}
/// With the "always" auth policy, requests should fail if only
/// unauthenticated requests are supported.
#[test(tokio::test)]
async fn test_auth_policy_always_unauthenticated() -> Result<(), Error> {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex("/*"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let base_url = Url::parse(&server.uri())?;
let auth_policies = auth_policies_for(&base_url, AuthPolicy::Always);
let client = test_client_builder()
.with(
AuthMiddleware::new()
.with_cache(CredentialsCache::new())
.with_url_auth_policies(auth_policies),
)
.build();
// Unauthenticated requests are not allowed.
assert!(matches!(
client.get(server.uri()).send().await,
Err(reqwest_middleware::Error::Middleware(_))
),);
Ok(())
}
/// With the "never" auth policy, requests should fail if
/// an endpoint requires authentication.
#[test(tokio::test)]
async fn test_auth_policy_never_with_credentials() -> Result<(), Error> {
let username = "user";
let password = "password";
let server = start_test_server(username, password).await;
let base_url = Url::parse(&server.uri())?;
Mock::given(method("GET"))
.and(path_regex("/*"))
.and(basic_auth(username, password))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let auth_policies = auth_policies_for(&base_url, AuthPolicy::Never);
let client = test_client_builder()
.with(
AuthMiddleware::new()
.with_cache(CredentialsCache::new())
.with_url_auth_policies(auth_policies),
)
.build();
let mut url = base_url.clone();
url.set_username(username).unwrap();
url.set_password(Some(password)).unwrap();
assert_eq!(
client
.get(format!("{}/foo", server.uri()))
.send()
.await?
.status(),
401,
"Requests should not be completed if credentials are required"
);
Ok(())
}
/// With the "never" auth policy, requests should succeed if
/// unauthenticated requests succeed.
#[test(tokio::test)]
async fn test_auth_policy_never_unauthenticated() -> Result<(), Error> {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex("/*"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let base_url = Url::parse(&server.uri())?;
let auth_policies = auth_policies_for(&base_url, AuthPolicy::Never);
let client = test_client_builder()
.with(
AuthMiddleware::new()
.with_cache(CredentialsCache::new())
.with_url_auth_policies(auth_policies),
)
.build();
assert_eq!(
client.get(server.uri()).send().await?.status(),
200,
"Requests should succeed if unauthenticated requests can succeed"
);
Ok(())
}
}

View File

@ -0,0 +1,54 @@
use rustc_hash::FxHashMap;
use url::Url;
#[derive(
Copy, Clone, Debug, Default, Hash, Eq, PartialEq, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "kebab-case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum AuthPolicy {
/// Try unauthenticated request. Fallback to authenticated request.
#[default]
Auto,
/// Always authenticate.
Always,
/// Never authenticate.
Never,
}
#[derive(Debug, Default, Clone, Eq, PartialEq)]
pub struct UrlAuthPolicies(FxHashMap<Url, AuthPolicy>);
impl UrlAuthPolicies {
pub fn new() -> Self {
Self(FxHashMap::default())
}
/// Create a new [`UrlAuthPolicies`] from a list of URL and [`AuthPolicy`]
/// tuples.
pub fn from_tuples(tuples: impl IntoIterator<Item = (Url, AuthPolicy)>) -> Self {
let mut auth_policies = Self::new();
for (url, auth_policy) in tuples {
auth_policies.add_policy(url, auth_policy);
}
auth_policies
}
/// An [`AuthPolicy`] for a URL.
pub fn add_policy(&mut self, url: Url, auth_policy: AuthPolicy) {
self.0.insert(url, auth_policy);
}
/// Get the [`AuthPolicy`] for a URL.
pub fn policy_for(&self, url: &Url) -> AuthPolicy {
// TODO(john): There are probably not many URLs to iterate through,
// but we could use a trie instead of a HashMap here for more
// efficient search.
for (auth_url, auth_policy) in &self.0 {
if url.as_str().starts_with(auth_url.as_str()) {
return *auth_policy;
}
}
AuthPolicy::Auto
}
}

View File

@ -13,7 +13,7 @@ use std::time::Duration;
use std::{env, iter};
use tracing::{debug, trace};
use url::Url;
use uv_auth::AuthMiddleware;
use uv_auth::{AuthMiddleware, UrlAuthPolicies};
use uv_configuration::{KeyringProviderType, TrustedHost};
use uv_fs::Simplified;
use uv_pep508::MarkerEnvironment;
@ -54,6 +54,7 @@ pub struct BaseClientBuilder<'a> {
markers: Option<&'a MarkerEnvironment>,
platform: Option<&'a Platform>,
auth_integration: AuthIntegration,
url_auth_policies: Option<UrlAuthPolicies>,
default_timeout: Duration,
extra_middleware: Option<ExtraMiddleware>,
}
@ -88,6 +89,7 @@ impl BaseClientBuilder<'_> {
markers: None,
platform: None,
auth_integration: AuthIntegration::default(),
url_auth_policies: None,
default_timeout: Duration::from_secs(30),
extra_middleware: None,
}
@ -149,6 +151,12 @@ impl<'a> BaseClientBuilder<'a> {
self
}
#[must_use]
pub fn url_auth_policies(mut self, auth_policies: UrlAuthPolicies) -> Self {
self.url_auth_policies = Some(auth_policies);
self
}
#[must_use]
pub fn default_timeout(mut self, default_timeout: Duration) -> Self {
self.default_timeout = default_timeout;
@ -324,8 +332,13 @@ impl<'a> BaseClientBuilder<'a> {
// Initialize the authentication middleware to set headers.
match self.auth_integration {
AuthIntegration::Default => {
client = client
.with(AuthMiddleware::new().with_keyring(self.keyring.to_provider()));
let mut auth_middleware =
AuthMiddleware::new().with_keyring(self.keyring.to_provider());
if let Some(url_auth_policies) = &self.url_auth_policies {
auth_middleware =
auth_middleware.with_url_auth_policies(url_auth_policies.clone());
}
client = client.with(auth_middleware);
}
AuthIntegration::OnlyAuthenticated => {
client = client.with(

View File

@ -13,6 +13,7 @@ use reqwest_middleware::ClientWithMiddleware;
use tokio::sync::Semaphore;
use tracing::{info_span, instrument, trace, warn, Instrument};
use url::Url;
use uv_auth::UrlAuthPolicies;
use crate::base_client::{BaseClientBuilder, ExtraMiddleware};
use crate::cached_client::CacheControl;
@ -100,6 +101,14 @@ impl<'a> RegistryClientBuilder<'a> {
self
}
#[must_use]
pub fn url_auth_policies(mut self, url_auth_policies: UrlAuthPolicies) -> Self {
self.base_client_builder = self
.base_client_builder
.url_auth_policies(url_auth_policies);
self
}
#[must_use]
pub fn cache(mut self, cache: Cache) -> Self {
self.cache = cache;

View File

@ -16,7 +16,7 @@ doctest = false
workspace = true
[dependencies]
uv-auth = { workspace = true }
uv-auth = { workspace = true, features = ["schemars"] }
uv-cache-info = { workspace = true }
uv-cache-key = { workspace = true }
uv-distribution-filename = { workspace = true }

View File

@ -4,7 +4,7 @@ use std::str::FromStr;
use thiserror::Error;
use url::Url;
use uv_auth::Credentials;
use uv_auth::{AuthPolicy, Credentials};
use crate::index_name::{IndexName, IndexNameError};
use crate::origin::Origin;
@ -82,6 +82,28 @@ pub struct Index {
/// publish-url = "https://upload.pypi.org/legacy/"
/// ```
pub publish_url: Option<Url>,
/// The authentication policy for the index.
///
/// There are three policies: "auto", "always", and "never".
///
/// * "auto" will first attempt an unauthenticated request to the index.
/// If that fails it will attempt an authenticated request.
/// * "always" will always attempt to make an authenticated request and will
/// fail if the authenticated request fails.
/// * "never" will never attempt to make an authenticated request and will
/// fail if an authenticated request fails.
///
/// The authentication policy will apply to requests made to URLs with
/// this index URL as a prefix.
///
/// ```toml
/// [[tool.uv.index]]
/// name = "my-index"
/// url = "https://<omitted>/simple"
/// authenticate = "always"
/// ```
#[serde(default)]
pub authenticate: AuthPolicy,
}
// #[derive(
@ -106,6 +128,7 @@ impl Index {
default: true,
origin: None,
publish_url: None,
authenticate: AuthPolicy::default(),
}
}
@ -118,6 +141,7 @@ impl Index {
default: false,
origin: None,
publish_url: None,
authenticate: AuthPolicy::default(),
}
}
@ -130,6 +154,7 @@ impl Index {
default: false,
origin: None,
publish_url: None,
authenticate: AuthPolicy::default(),
}
}
@ -160,20 +185,7 @@ impl Index {
/// For indexes with a `/simple` endpoint, this is simply the URL with the final segment
/// removed. This is useful, e.g., for credential propagation to other endpoints on the index.
pub fn root_url(&self) -> Option<Url> {
let mut segments = self.raw_url().path_segments()?;
let last = match segments.next_back()? {
// If the last segment is empty due to a trailing `/`, skip it (as in `pop_if_empty`)
"" => segments.next_back()?,
segment => segment,
};
if !last.eq_ignore_ascii_case("simple") {
return None;
}
let mut url = self.raw_url().clone();
url.path_segments_mut().ok()?.pop_if_empty().pop();
Some(url)
self.url.root()
}
/// Retrieve the credentials for the index, either from the environment, or from the URL itself.
@ -216,6 +228,7 @@ impl FromStr for Index {
default: false,
origin: None,
publish_url: None,
authenticate: AuthPolicy::default(),
});
}
}
@ -229,6 +242,7 @@ impl FromStr for Index {
default: false,
origin: None,
publish_url: None,
authenticate: AuthPolicy::default(),
})
}
}

View File

@ -10,6 +10,7 @@ use rustc_hash::{FxHashMap, FxHashSet};
use thiserror::Error;
use url::{ParseError, Url};
use uv_auth::UrlAuthPolicies;
use uv_pep508::{split_scheme, Scheme, VerbatimUrl, VerbatimUrlError};
use crate::{Index, Verbatim};
@ -64,6 +65,27 @@ impl IndexUrl {
};
Ok(Self::from(url.with_given(path)))
}
/// Return the root [`Url`] of the index, if applicable.
///
/// For indexes with a `/simple` endpoint, this is simply the URL with the final segment
/// removed. This is useful, e.g., for credential propagation to other endpoints on the index.
pub fn root(&self) -> Option<Url> {
let mut segments = self.url().path_segments()?;
let last = match segments.next_back()? {
// If the last segment is empty due to a trailing `/`, skip it (as in `pop_if_empty`)
"" => segments.next_back()?,
segment => segment,
};
if !last.eq_ignore_ascii_case("simple") {
return None;
}
let mut url = self.url().clone();
url.path_segments_mut().ok()?.pop_if_empty().pop();
Some(url)
}
}
#[cfg(feature = "schemars")]
@ -389,6 +411,20 @@ impl<'a> IndexLocations {
}
}
impl From<&IndexLocations> for UrlAuthPolicies {
fn from(index_locations: &IndexLocations) -> UrlAuthPolicies {
UrlAuthPolicies::from_tuples(index_locations.indexes().map(|index| {
let mut url = index
.url()
.root()
.unwrap_or_else(|| index.url().url().clone());
url.set_username("").ok();
url.set_password(None).ok();
(url, index.authenticate)
}))
}
}
/// The index URLs to use for fetching packages.
///
/// This type merges the legacy `--index-url` and `--extra-index-url` options, along with the

View File

@ -10,6 +10,7 @@ use anyhow::{Context, Result};
use owo_colors::OwoColorize;
use thiserror::Error;
use tracing::instrument;
use uv_auth::UrlAuthPolicies;
use crate::commands::pip::operations;
use crate::commands::project::{find_requires_python, ProjectError};
@ -520,10 +521,11 @@ async fn build_package(
let client = RegistryClientBuilder::new(cache.clone())
.native_tls(network_settings.native_tls)
.connectivity(network_settings.connectivity)
.allow_insecure_host(network_settings.allow_insecure_host.clone())
.url_auth_policies(UrlAuthPolicies::from(index_locations))
.index_urls(index_locations.index_urls())
.index_strategy(index_strategy)
.keyring(keyring_provider)
.allow_insecure_host(network_settings.allow_insecure_host.clone())
.markers(interpreter.markers())
.platform(interpreter.platform())
.build();

View File

@ -87,10 +87,10 @@ pub(crate) async fn pip_list(
RegistryClientBuilder::new(cache.clone().with_refresh(Refresh::All(Timestamp::now())))
.native_tls(network_settings.native_tls)
.connectivity(network_settings.connectivity)
.allow_insecure_host(network_settings.allow_insecure_host.clone())
.index_urls(index_locations.index_urls())
.index_strategy(index_strategy)
.keyring(keyring_provider)
.allow_insecure_host(network_settings.allow_insecure_host.clone())
.markers(environment.interpreter().markers())
.platform(environment.interpreter().platform())
.build();

View File

@ -88,10 +88,10 @@ pub(crate) async fn pip_tree(
RegistryClientBuilder::new(cache.clone().with_refresh(Refresh::All(Timestamp::now())))
.native_tls(network_settings.native_tls)
.connectivity(network_settings.connectivity)
.allow_insecure_host(network_settings.allow_insecure_host.clone())
.index_urls(index_locations.index_urls())
.index_strategy(index_strategy)
.keyring(keyring_provider)
.allow_insecure_host(network_settings.allow_insecure_host.clone())
.markers(environment.interpreter().markers())
.platform(environment.interpreter().platform())
.build();

View File

@ -9,6 +9,7 @@ use owo_colors::OwoColorize;
use rustc_hash::{FxBuildHasher, FxHashMap};
use tracing::debug;
use uv_auth::UrlAuthPolicies;
use uv_cache::Cache;
use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder};
use uv_configuration::{
@ -546,10 +547,11 @@ async fn do_lock(
let client = RegistryClientBuilder::new(cache.clone())
.native_tls(network_settings.native_tls)
.connectivity(network_settings.connectivity)
.allow_insecure_host(network_settings.allow_insecure_host.clone())
.url_auth_policies(UrlAuthPolicies::from(index_locations))
.index_urls(index_locations.index_urls())
.index_strategy(index_strategy)
.keyring(keyring_provider)
.allow_insecure_host(network_settings.allow_insecure_host.clone())
.markers(interpreter.markers())
.platform(interpreter.platform())
.build();

View File

@ -8,6 +8,7 @@ use itertools::Itertools;
use owo_colors::OwoColorize;
use tracing::{debug, warn};
use uv_auth::UrlAuthPolicies;
use uv_cache::{Cache, CacheBucket};
use uv_cache_key::cache_digest;
use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder};
@ -1536,10 +1537,11 @@ pub(crate) async fn resolve_names(
let client = RegistryClientBuilder::new(cache.clone())
.native_tls(network_settings.native_tls)
.connectivity(network_settings.connectivity)
.allow_insecure_host(network_settings.allow_insecure_host.clone())
.url_auth_policies(UrlAuthPolicies::from(index_locations))
.index_urls(index_locations.index_urls())
.index_strategy(*index_strategy)
.keyring(*keyring_provider)
.allow_insecure_host(network_settings.allow_insecure_host.clone())
.markers(interpreter.markers())
.platform(interpreter.platform())
.build();
@ -1687,10 +1689,11 @@ pub(crate) async fn resolve_environment(
let client = RegistryClientBuilder::new(cache.clone())
.native_tls(network_settings.native_tls)
.connectivity(network_settings.connectivity)
.allow_insecure_host(network_settings.allow_insecure_host.clone())
.url_auth_policies(UrlAuthPolicies::from(index_locations))
.index_urls(index_locations.index_urls())
.index_strategy(index_strategy)
.keyring(keyring_provider)
.allow_insecure_host(network_settings.allow_insecure_host.clone())
.markers(interpreter.markers())
.platform(interpreter.platform())
.build();
@ -1856,10 +1859,11 @@ pub(crate) async fn sync_environment(
let client = RegistryClientBuilder::new(cache.clone())
.native_tls(network_settings.native_tls)
.connectivity(network_settings.connectivity)
.allow_insecure_host(network_settings.allow_insecure_host.clone())
.url_auth_policies(UrlAuthPolicies::from(index_locations))
.index_urls(index_locations.index_urls())
.index_strategy(index_strategy)
.keyring(keyring_provider)
.allow_insecure_host(network_settings.allow_insecure_host.clone())
.markers(interpreter.markers())
.platform(interpreter.platform())
.build();
@ -2058,10 +2062,11 @@ pub(crate) async fn update_environment(
let client = RegistryClientBuilder::new(cache.clone())
.native_tls(network_settings.native_tls)
.connectivity(network_settings.connectivity)
.allow_insecure_host(network_settings.allow_insecure_host.clone())
.url_auth_policies(UrlAuthPolicies::from(index_locations))
.index_urls(index_locations.index_urls())
.index_strategy(*index_strategy)
.keyring(*keyring_provider)
.allow_insecure_host(network_settings.allow_insecure_host.clone())
.markers(interpreter.markers())
.platform(interpreter.platform())
.build();

View File

@ -7,6 +7,7 @@ use anyhow::{Context, Result};
use itertools::Itertools;
use owo_colors::OwoColorize;
use uv_auth::UrlAuthPolicies;
use uv_cache::Cache;
use uv_client::{FlatIndexClient, RegistryClientBuilder};
use uv_configuration::{
@ -622,10 +623,11 @@ pub(super) async fn do_sync(
let client = RegistryClientBuilder::new(cache.clone())
.native_tls(network_settings.native_tls)
.connectivity(network_settings.connectivity)
.allow_insecure_host(network_settings.allow_insecure_host.clone())
.url_auth_policies(UrlAuthPolicies::from(index_locations))
.index_urls(index_locations.index_urls())
.index_strategy(index_strategy)
.keyring(keyring_provider)
.allow_insecure_host(network_settings.allow_insecure_host.clone())
.markers(venv.interpreter().markers())
.platform(venv.interpreter().platform())
.build();

View File

@ -4,6 +4,7 @@ use anstream::print;
use anyhow::{Error, Result};
use futures::StreamExt;
use tokio::sync::Semaphore;
use uv_auth::UrlAuthPolicies;
use uv_cache::{Cache, Refresh};
use uv_cache_info::Timestamp;
use uv_client::RegistryClientBuilder;
@ -180,7 +181,7 @@ pub(crate) async fn tree(
PackageMap::default()
} else {
let ResolverSettings {
index_locations: _,
index_locations,
index_strategy: _,
keyring_provider,
resolution: _,
@ -205,8 +206,9 @@ pub(crate) async fn tree(
)
.native_tls(network_settings.native_tls)
.connectivity(network_settings.connectivity)
.keyring(*keyring_provider)
.allow_insecure_host(network_settings.allow_insecure_host.clone())
.url_auth_policies(UrlAuthPolicies::from(index_locations))
.keyring(*keyring_provider)
.build();
let download_concurrency = Semaphore::new(concurrency.downloads);

View File

@ -95,9 +95,9 @@ pub(crate) async fn publish(
let registry_client_builder = RegistryClientBuilder::new(cache.clone())
.native_tls(network_settings.native_tls)
.connectivity(network_settings.connectivity)
.allow_insecure_host(network_settings.allow_insecure_host.clone())
.index_urls(index_urls)
.keyring(keyring_provider)
.allow_insecure_host(network_settings.allow_insecure_host.clone());
.keyring(keyring_provider);
Some(CheckUrlClient {
index_url: index_url.clone(),
registry_client_builder,

View File

@ -2473,23 +2473,24 @@ impl ResolverSettings {
impl From<ResolverOptions> for ResolverSettings {
fn from(value: ResolverOptions) -> Self {
let index_locations = IndexLocations::new(
value
.index
.into_iter()
.flatten()
.chain(value.extra_index_url.into_iter().flatten().map(Index::from))
.chain(value.index_url.into_iter().map(Index::from))
.collect(),
value
.find_links
.into_iter()
.flatten()
.map(Index::from)
.collect(),
value.no_index.unwrap_or_default(),
);
Self {
index_locations: IndexLocations::new(
value
.index
.into_iter()
.flatten()
.chain(value.extra_index_url.into_iter().flatten().map(Index::from))
.chain(value.index_url.into_iter().map(Index::from))
.collect(),
value
.find_links
.into_iter()
.flatten()
.map(Index::from)
.collect(),
value.no_index.unwrap_or_default(),
),
index_locations,
resolution: value.resolution.unwrap_or_default(),
prerelease: value.prerelease.unwrap_or_default(),
fork_strategy: value.fork_strategy.unwrap_or_default(),
@ -2610,23 +2611,24 @@ impl ResolverInstallerSettings {
impl From<ResolverInstallerOptions> for ResolverInstallerSettings {
fn from(value: ResolverInstallerOptions) -> Self {
let index_locations = IndexLocations::new(
value
.index
.into_iter()
.flatten()
.chain(value.extra_index_url.into_iter().flatten().map(Index::from))
.chain(value.index_url.into_iter().map(Index::from))
.collect(),
value
.find_links
.into_iter()
.flatten()
.map(Index::from)
.collect(),
value.no_index.unwrap_or_default(),
);
Self {
index_locations: IndexLocations::new(
value
.index
.into_iter()
.flatten()
.chain(value.extra_index_url.into_iter().flatten().map(Index::from))
.chain(value.index_url.into_iter().map(Index::from))
.collect(),
value
.find_links
.into_iter()
.flatten()
.map(Index::from)
.collect(),
value.no_index.unwrap_or_default(),
),
index_locations,
resolution: value.resolution.unwrap_or_default(),
prerelease: value.prerelease.unwrap_or_default(),
fork_strategy: value.fork_strategy.unwrap_or_default(),

View File

@ -10194,3 +10194,198 @@ fn add_unsupported_git_scheme() {
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
"###);
}
/// In authentication "always", the normal authentication flow should still work.
#[test]
fn add_auth_policy_always_with_credentials() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! { r#"
[project]
name = "foo"
version = "1.0.0"
requires-python = ">=3.11, <4"
dependencies = []
[[tool.uv.index]]
name = "my-index"
url = "https://pypi-proxy.fly.dev/basic-auth/simple"
authenticate = "always"
default = true
"#
})?;
uv_snapshot!(context.add().arg("anyio")
.env("UV_INDEX_MY_INDEX_USERNAME", "public")
.env("UV_INDEX_MY_INDEX_PASSWORD", "heron"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 4 packages in [TIME]
Prepared 3 packages in [TIME]
Installed 3 packages in [TIME]
+ anyio==4.3.0
+ idna==3.6
+ sniffio==1.3.1
"
);
context.assert_command("import anyio").success();
Ok(())
}
/// In authentication "always", unauthenticated requests to a registry that
/// doesn't require credentials will fail.
#[test]
fn add_auth_policy_always_without_credentials() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! { r#"
[project]
name = "foo"
version = "1.0.0"
requires-python = ">=3.11, <4"
dependencies = []
[[tool.uv.index]]
name = "my-index"
url = "https://pypi.org/simple"
authenticate = "always"
default = true
"#
})?;
uv_snapshot!(context.add().arg("anyio"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Failed to fetch: `https://pypi.org/simple/anyio/`
Caused by: Missing credentials for https://pypi.org/simple/anyio/
"
);
Ok(())
}
/// In authentication "never", even if the correct credentials are supplied
/// in the URL, no authenticated requests will be allowed.
#[test]
fn add_auth_policy_never_with_url_credentials() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! { r#"
[project]
name = "foo"
version = "1.0.0"
requires-python = ">=3.11, <4"
dependencies = []
[[tool.uv.index]]
name = "my-index"
url = "https://public:heron@pypi-proxy.fly.dev/basic-auth/simple"
authenticate = "never"
default = true
"#
})?;
uv_snapshot!(context.add().arg("anyio"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Failed to fetch: `https://pypi-proxy.fly.dev/basic-auth/files/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl.metadata`
Caused by: HTTP status client error (401 Unauthorized) for url (https://pypi-proxy.fly.dev/basic-auth/files/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl.metadata)
"
);
Ok(())
}
/// In authentication "never", even if the correct credentials are supplied
/// via env vars, no authenticated requests will be allowed.
#[test]
fn add_auth_policy_never_with_env_var_credentials() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! { r#"
[project]
name = "foo"
version = "1.0.0"
requires-python = ">=3.11, <4"
dependencies = []
[[tool.uv.index]]
name = "my-index"
url = "https://pypi-proxy.fly.dev/basic-auth/simple"
authenticate = "never"
default = true
"#
})?;
uv_snapshot!(context.add().arg("anyio")
.env("UV_INDEX_MY_INDEX_USERNAME", "public")
.env("UV_INDEX_MY_INDEX_PASSWORD", "heron"), @r"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× No solution found when resolving dependencies:
Because anyio was not found in the package registry and your project depends on anyio, we can conclude that your project's requirements are unsatisfiable.
hint: An index URL (https://pypi-proxy.fly.dev/basic-auth/simple) could not be queried due to a lack of valid authentication credentials (401 Unauthorized).
help: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing.
"
);
Ok(())
}
/// In authentication "never", the normal flow for unauthenticated requests should
/// still work.
#[test]
fn add_auth_policy_never_without_credentials() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! { r#"
[project]
name = "foo"
version = "1.0.0"
requires-python = ">=3.11, <4"
dependencies = []
[[tool.uv.index]]
name = "my-index"
url = "https://pypi.org/simple"
authenticate = "never"
default = true
"#
})?;
uv_snapshot!(context.add().arg("anyio"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 4 packages in [TIME]
Prepared 3 packages in [TIME]
Installed 3 packages in [TIME]
+ anyio==4.3.0
+ idna==3.6
+ sniffio==1.3.1
"
);
context.assert_command("import anyio").success();
Ok(())
}

View File

@ -135,6 +135,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> {
default: true,
origin: None,
publish_url: None,
authenticate: Auto,
},
],
flat_index: [],
@ -309,6 +310,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> {
default: true,
origin: None,
publish_url: None,
authenticate: Auto,
},
],
flat_index: [],
@ -484,6 +486,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> {
default: true,
origin: None,
publish_url: None,
authenticate: Auto,
},
],
flat_index: [],
@ -691,6 +694,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> {
default: true,
origin: None,
publish_url: None,
authenticate: Auto,
},
],
flat_index: [],
@ -1022,6 +1026,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> {
default: true,
origin: None,
publish_url: None,
authenticate: Auto,
},
],
flat_index: [],
@ -1221,6 +1226,7 @@ fn resolve_index_url() -> anyhow::Result<()> {
default: false,
origin: None,
publish_url: None,
authenticate: Auto,
},
Index {
name: None,
@ -1250,6 +1256,7 @@ fn resolve_index_url() -> anyhow::Result<()> {
default: true,
origin: None,
publish_url: None,
authenticate: Auto,
},
],
flat_index: [],
@ -1428,6 +1435,7 @@ fn resolve_index_url() -> anyhow::Result<()> {
Cli,
),
publish_url: None,
authenticate: Auto,
},
Index {
name: None,
@ -1457,6 +1465,7 @@ fn resolve_index_url() -> anyhow::Result<()> {
default: false,
origin: None,
publish_url: None,
authenticate: Auto,
},
Index {
name: None,
@ -1486,6 +1495,7 @@ fn resolve_index_url() -> anyhow::Result<()> {
default: true,
origin: None,
publish_url: None,
authenticate: Auto,
},
],
flat_index: [],
@ -1686,6 +1696,7 @@ fn resolve_find_links() -> anyhow::Result<()> {
default: false,
origin: None,
publish_url: None,
authenticate: Auto,
},
],
no_index: true,
@ -2044,6 +2055,7 @@ fn resolve_top_level() -> anyhow::Result<()> {
default: false,
origin: None,
publish_url: None,
authenticate: Auto,
},
Index {
name: None,
@ -2073,6 +2085,7 @@ fn resolve_top_level() -> anyhow::Result<()> {
default: false,
origin: None,
publish_url: None,
authenticate: Auto,
},
],
flat_index: [],
@ -2247,6 +2260,7 @@ fn resolve_top_level() -> anyhow::Result<()> {
default: false,
origin: None,
publish_url: None,
authenticate: Auto,
},
Index {
name: None,
@ -2276,6 +2290,7 @@ fn resolve_top_level() -> anyhow::Result<()> {
default: false,
origin: None,
publish_url: None,
authenticate: Auto,
},
],
flat_index: [],
@ -3441,6 +3456,7 @@ fn resolve_both() -> anyhow::Result<()> {
default: true,
origin: None,
publish_url: None,
authenticate: Auto,
},
],
flat_index: [],
@ -3738,6 +3754,7 @@ fn resolve_config_file() -> anyhow::Result<()> {
default: true,
origin: None,
publish_url: None,
authenticate: Auto,
},
],
flat_index: [],
@ -4507,6 +4524,7 @@ fn index_priority() -> anyhow::Result<()> {
Cli,
),
publish_url: None,
authenticate: Auto,
},
Index {
name: None,
@ -4536,6 +4554,7 @@ fn index_priority() -> anyhow::Result<()> {
default: false,
origin: None,
publish_url: None,
authenticate: Auto,
},
],
flat_index: [],
@ -4712,6 +4731,7 @@ fn index_priority() -> anyhow::Result<()> {
Cli,
),
publish_url: None,
authenticate: Auto,
},
Index {
name: None,
@ -4741,6 +4761,7 @@ fn index_priority() -> anyhow::Result<()> {
default: false,
origin: None,
publish_url: None,
authenticate: Auto,
},
],
flat_index: [],
@ -4923,6 +4944,7 @@ fn index_priority() -> anyhow::Result<()> {
Cli,
),
publish_url: None,
authenticate: Auto,
},
Index {
name: None,
@ -4952,6 +4974,7 @@ fn index_priority() -> anyhow::Result<()> {
default: true,
origin: None,
publish_url: None,
authenticate: Auto,
},
],
flat_index: [],
@ -5129,6 +5152,7 @@ fn index_priority() -> anyhow::Result<()> {
Cli,
),
publish_url: None,
authenticate: Auto,
},
Index {
name: None,
@ -5158,6 +5182,7 @@ fn index_priority() -> anyhow::Result<()> {
default: true,
origin: None,
publish_url: None,
authenticate: Auto,
},
],
flat_index: [],
@ -5342,6 +5367,7 @@ fn index_priority() -> anyhow::Result<()> {
Cli,
),
publish_url: None,
authenticate: Auto,
},
Index {
name: None,
@ -5371,6 +5397,7 @@ fn index_priority() -> anyhow::Result<()> {
default: true,
origin: None,
publish_url: None,
authenticate: Auto,
},
],
flat_index: [],
@ -5548,6 +5575,7 @@ fn index_priority() -> anyhow::Result<()> {
Cli,
),
publish_url: None,
authenticate: Auto,
},
Index {
name: None,
@ -5577,6 +5605,7 @@ fn index_priority() -> anyhow::Result<()> {
default: true,
origin: None,
publish_url: None,
authenticate: Auto,
},
],
flat_index: [],

View File

@ -98,3 +98,9 @@ security risks due to the lack of certificate verification.
See the [alternative indexes integration guide](../guides/integration/alternative-indexes.md) for
details on authentication with popular alternative Python package indexes.
## Configuring authentication for indexes
It is possible to configure how uv will handle authentication for requests to indexes. See
[configuring authentication for indexes](indexes.md#configuring-authentication-for-indexes) for more
details.

View File

@ -175,6 +175,28 @@ url = "https://public:koala@pypi-proxy.corp.dev/simple"
For security purposes, credentials are _never_ stored in the `uv.lock` file; as such, uv _must_ have
access to the authenticated URL at installation time.
## Configuring authentication for indexes
By default, when sending requests to an index, uv will first attempt an unauthenticated request. If
that fails, it will search for credentials and attempt an authenticated request.
It is possible to change this default behavior for an index by specifying when to authenticate:
```toml
[[tool.uv.index]]
name = "example"
url = "https://example.com/simple"
authenticate = "always"
```
The following values are supported for `authenticate`:
- `auto` (default): First attempt an unauthenticated request. If that fails, search for credentials
and attempt an authenticated request.
- `always`: Always search for credentials and attempt an authenticated request. If that fails, the
request fails.
- `never`: Only attempt an unauthenticated request. If that fails, the request fails.
## `--index-url` and `--extra-index-url`
In addition to the `[[tool.uv.index]]` configuration option, uv supports pip-style `--index-url` and

34
uv.schema.json generated
View File

@ -557,6 +557,31 @@
}
]
},
"AuthPolicy": {
"oneOf": [
{
"description": "Try unauthenticated request. Fallback to authenticated request.",
"type": "string",
"enum": [
"auto"
]
},
{
"description": "Always authenticate.",
"type": "string",
"enum": [
"always"
]
},
{
"description": "Never authenticate.",
"type": "string",
"enum": [
"never"
]
}
]
},
"CacheKey": {
"anyOf": [
{
@ -709,6 +734,15 @@
"url"
],
"properties": {
"authenticate": {
"description": "The authentication policy for the index.\n\nThere are three policies: \"auto\", \"always\", and \"never\".\n\n* \"auto\" will first attempt an unauthenticated request to the index. If that fails it will attempt an authenticated request. * \"always\" will always attempt to make an authenticated request and will fail if the authenticated request fails. * \"never\" will never attempt to make an authenticated request and will fail if an authenticated request fails.\n\nThe authentication policy will apply to requests made to URLs with this index URL as a prefix.\n\n```toml [[tool.uv.index]] name = \"my-index\" url = \"https://<omitted>/simple\" authenticate = \"always\" ```",
"default": "auto",
"allOf": [
{
"$ref": "#/definitions/AuthPolicy"
}
]
},
"default": {
"description": "Mark the index as the default index.\n\nBy default, uv uses PyPI as the default index, such that even if additional indexes are defined via `[[tool.uv.index]]`, PyPI will still be used as a fallback for packages that aren't found elsewhere. To disable the PyPI default, set `default = true` on at least one other index.\n\nMarking an index as default will move it to the front of the list of indexes, such that it is given the highest priority when resolving packages.",
"default": false,