mirror of https://github.com/astral-sh/uv
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:
parent
44c3648537
commit
c58675fdac
|
|
@ -4612,6 +4612,8 @@ dependencies = [
|
|||
"reqwest-middleware",
|
||||
"rust-netrc",
|
||||
"rustc-hash",
|
||||
"schemars",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"test-log",
|
||||
"tokio",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue