diff --git a/Cargo.lock b/Cargo.lock index 05dceb1c2..4ab598387 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4612,6 +4612,8 @@ dependencies = [ "reqwest-middleware", "rust-netrc", "rustc-hash", + "schemars", + "serde", "tempfile", "test-log", "tokio", diff --git a/crates/uv-auth/Cargo.toml b/crates/uv-auth/Cargo.toml index 9c454a357..22608cc2c 100644 --- a/crates/uv-auth/Cargo.toml +++ b/crates/uv-auth/Cargo.toml @@ -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 } diff --git a/crates/uv-auth/src/credentials.rs b/crates/uv-auth/src/credentials.rs index 82408b556..a0a79133d 100644 --- a/crates/uv-auth/src/credentials.rs +++ b/crates/uv-auth/src/credentials.rs @@ -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) -> 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 { diff --git a/crates/uv-auth/src/lib.rs b/crates/uv-auth/src/lib.rs index a01c5a528..1c090e73f 100644 --- a/crates/uv-auth/src/lib.rs +++ b/crates/uv-auth/src/lib.rs @@ -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 diff --git a/crates/uv-auth/src/middleware.rs b/crates/uv-auth/src/middleware.rs index 8a59b91c5..042adad95 100644 --- a/crates/uv-auth/src/middleware.rs +++ b/crates/uv-auth/src/middleware.rs @@ -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, cache: Option, - /// 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 { // 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> = 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 { + 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(()) + } } diff --git a/crates/uv-auth/src/policy.rs b/crates/uv-auth/src/policy.rs new file mode 100644 index 000000000..23c0652ff --- /dev/null +++ b/crates/uv-auth/src/policy.rs @@ -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); + +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) -> 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 + } +} diff --git a/crates/uv-client/src/base_client.rs b/crates/uv-client/src/base_client.rs index 3c7a5eb71..ca4393964 100644 --- a/crates/uv-client/src/base_client.rs +++ b/crates/uv-client/src/base_client.rs @@ -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, default_timeout: Duration, extra_middleware: Option, } @@ -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( diff --git a/crates/uv-client/src/registry_client.rs b/crates/uv-client/src/registry_client.rs index ec636775d..05ce1982e 100644 --- a/crates/uv-client/src/registry_client.rs +++ b/crates/uv-client/src/registry_client.rs @@ -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; diff --git a/crates/uv-distribution-types/Cargo.toml b/crates/uv-distribution-types/Cargo.toml index b4be1aece..e5a723c43 100644 --- a/crates/uv-distribution-types/Cargo.toml +++ b/crates/uv-distribution-types/Cargo.toml @@ -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 } diff --git a/crates/uv-distribution-types/src/index.rs b/crates/uv-distribution-types/src/index.rs index d949c3776..57092003d 100644 --- a/crates/uv-distribution-types/src/index.rs +++ b/crates/uv-distribution-types/src/index.rs @@ -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, + /// 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:///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 { - 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(), }) } } diff --git a/crates/uv-distribution-types/src/index_url.rs b/crates/uv-distribution-types/src/index_url.rs index 69bce9664..2b73313f2 100644 --- a/crates/uv-distribution-types/src/index_url.rs +++ b/crates/uv-distribution-types/src/index_url.rs @@ -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 { + 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 diff --git a/crates/uv/src/commands/build_frontend.rs b/crates/uv/src/commands/build_frontend.rs index 6aac93445..01de12771 100644 --- a/crates/uv/src/commands/build_frontend.rs +++ b/crates/uv/src/commands/build_frontend.rs @@ -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(); diff --git a/crates/uv/src/commands/pip/list.rs b/crates/uv/src/commands/pip/list.rs index e87022d67..cebe2a739 100644 --- a/crates/uv/src/commands/pip/list.rs +++ b/crates/uv/src/commands/pip/list.rs @@ -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(); diff --git a/crates/uv/src/commands/pip/tree.rs b/crates/uv/src/commands/pip/tree.rs index 16d376a4c..abd9ff44a 100644 --- a/crates/uv/src/commands/pip/tree.rs +++ b/crates/uv/src/commands/pip/tree.rs @@ -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(); diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 231614f9e..715ffb6e5 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -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(); diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 32cb28ec2..ee12cc34b 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -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(); diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 85b91f391..e62e34217 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -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(); diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index 54c4e07c3..582393e33 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -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); diff --git a/crates/uv/src/commands/publish.rs b/crates/uv/src/commands/publish.rs index b022abc34..b158b28ba 100644 --- a/crates/uv/src/commands/publish.rs +++ b/crates/uv/src/commands/publish.rs @@ -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, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index ae9c0e5f9..f77f093dd 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -2473,23 +2473,24 @@ impl ResolverSettings { impl From 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 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(), diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index 8d7d01bbf..020f0c676 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -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(()) +} diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 986dd60e0..063482043 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -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: [], diff --git a/docs/configuration/authentication.md b/docs/configuration/authentication.md index 8a6bcf002..e3d192f61 100644 --- a/docs/configuration/authentication.md +++ b/docs/configuration/authentication.md @@ -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. diff --git a/docs/configuration/indexes.md b/docs/configuration/indexes.md index 7e9327d6f..fb225f4c7 100644 --- a/docs/configuration/indexes.md +++ b/docs/configuration/indexes.md @@ -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 diff --git a/uv.schema.json b/uv.schema.json index 19bf94be6..ee732bb2e 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -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:///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,