Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 33 additions & 36 deletions crates/uv-auth/src/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -749,47 +749,44 @@ impl AuthMiddleware {
}

// If this is a known URL, authenticate it via the token store.
if let Some(base_client) = self.base_client.as_ref() {
if let Some(token_store) = self.pyx_token_store.as_ref() {
if token_store.is_known_url(url) {
let mut token_state = self.pyx_token_state.lock().await;

// If the token store is uninitialized, initialize it.
let token = match *token_state {
TokenState::Uninitialized => {
trace!("Initializing token store for {url}");
let generated = match token_store
.access_token(base_client, DEFAULT_TOLERANCE_SECS)
.await
{
Ok(Some(token)) => Some(token),
Ok(None) => None,
Err(err) => {
warn!("Failed to generate access tokens: {err}");
None
}
};
*token_state = TokenState::Initialized(generated.clone());
generated
let credentials = if let Some(credentials) = async {
let base_client = self.base_client.as_ref()?;
let token_store = self.pyx_token_store.as_ref()?;
if !token_store.is_known_url(url) {
return None;
}

let mut token_state = self.pyx_token_state.lock().await;

// If the token store is uninitialized, initialize it.
let token = match *token_state {
TokenState::Uninitialized => {
trace!("Initializing token store for {url}");
let generated = match token_store
.access_token(base_client, DEFAULT_TOLERANCE_SECS)
.await
{
Ok(Some(token)) => Some(token),
Ok(None) => None,
Err(err) => {
warn!("Failed to generate access tokens: {err}");
None
}
TokenState::Initialized(ref tokens) => tokens.clone(),
};

let credentials = token.map(|token| {
trace!("Using credentials from token store for {url}");
Arc::new(Authentication::from(Credentials::from(token)))
});

// Register the fetch for this key
self.cache().fetches.done(key.clone(), credentials.clone());

return credentials;
*token_state = TokenState::Initialized(generated.clone());
generated
}
}
}
TokenState::Initialized(ref tokens) => tokens.clone(),
};

token.map(Credentials::from)
}
.await
{
debug!("Found credentials from token store for {url}");
Some(credentials)
// Netrc support based on: <https://github.com/gribouille/netrc>.
let credentials = if let Some(credentials) = self.netrc.get().and_then(|netrc| {
} else if let Some(credentials) = self.netrc.get().and_then(|netrc| {
debug!("Checking netrc for credentials for {url}");
Credentials::from_netrc(
netrc,
Expand Down
147 changes: 147 additions & 0 deletions crates/uv/tests/it/pip_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5939,6 +5939,153 @@ async fn install_package_basic_auth_from_netrc() -> Result<()> {
Ok(())
}

/// Install a package from a known pyx URL by falling back to netrc when the pyx store is empty.
#[tokio::test]
async fn install_package_known_pyx_url_from_netrc_without_pyx_token() -> Result<()> {
let context = uv_test::test_context!("3.12");
let proxy = crate::pypi_proxy::start().await;
let netrc = context.temp_dir.child(".netrc");
netrc.write_str(&format!(
"machine {} login public password heron",
proxy.host()
))?;
let pyx_credentials_dir = context.temp_dir.child("pyx-credentials");
pyx_credentials_dir.create_dir_all()?;

uv_snapshot!(context.filters(), context.pip_install()
.arg("anyio")
.arg("--index-url")
.arg(proxy.url("/basic-auth/simple"))
.env(EnvVars::NETRC, netrc.as_os_str())
.env(EnvVars::PYX_API_URL, proxy.uri())
.env(EnvVars::PYX_CREDENTIALS_DIR, pyx_credentials_dir.as_os_str()), @"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Resolved 3 packages in [TIME]
Prepared 3 packages in [TIME]
Installed 3 packages in [TIME]
+ anyio==4.3.0
+ idna==3.6
+ sniffio==1.3.1
"
);

Ok(())
}

/// Install a package from a known pyx URL by falling back to netrc when the pyx lookup fails.
#[tokio::test]
async fn install_package_known_pyx_url_from_netrc_on_pyx_error() -> Result<()> {
let context = uv_test::test_context!("3.12");
let proxy = crate::pypi_proxy::start().await;
let netrc = context.temp_dir.child(".netrc");
netrc.write_str(&format!(
"machine {} login public password heron",
proxy.host()
))?;
let pyx_credentials_dir = context.temp_dir.child("pyx-credentials");
pyx_credentials_dir.create_dir_all()?;

uv_snapshot!(context.filters(), context.pip_install()
.arg("anyio")
.arg("--index-url")
.arg(proxy.url("/basic-auth/simple"))
.env(EnvVars::NETRC, netrc.as_os_str())
.env(EnvVars::PYX_API_URL, proxy.uri())
.env(EnvVars::PYX_API_KEY, "invalid-api-key")
.env(EnvVars::PYX_CREDENTIALS_DIR, pyx_credentials_dir.as_os_str()), @"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Resolved 3 packages in [TIME]
Prepared 3 packages in [TIME]
Installed 3 packages in [TIME]
+ anyio==4.3.0
+ idna==3.6
+ sniffio==1.3.1
"
);

Ok(())
}

/// Install a package from a known pyx URL using the pyx token even when netrc is available.
#[tokio::test]
async fn install_package_known_pyx_url_prefers_pyx_token_to_netrc() -> Result<()> {
let context = uv_test::test_context!("3.12");
let proxy = crate::pypi_proxy::start().await;
let netrc = context.temp_dir.child(".netrc");
netrc.write_str(&format!(
// Pass in an incorrect password so the test fails if we use it.
"machine {} login public password incorrect",
proxy.host()
))?;
let pyx_credentials_dir = context.temp_dir.child("pyx-credentials");
pyx_credentials_dir.create_dir_all()?;

uv_snapshot!(context.filters(), context.pip_install()
.arg("anyio")
.arg("--index-url")
.arg(proxy.url("/bearer-auth/simple"))
.env(EnvVars::NETRC, netrc.as_os_str())
.env(EnvVars::PYX_API_URL, proxy.uri())
.env(EnvVars::PYX_AUTH_TOKEN, crate::pypi_proxy::pyx_test_token())
.env(EnvVars::PYX_CREDENTIALS_DIR, pyx_credentials_dir.as_os_str())
.arg("--strict"), @"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Resolved 3 packages in [TIME]
Prepared 3 packages in [TIME]
Installed 3 packages in [TIME]
+ anyio==4.3.0
+ idna==3.6
+ sniffio==1.3.1
"
);

Ok(())
}

/// A known pyx URL with no relevant fallback credentials should still show pyx-specific guidance.
#[tokio::test]
async fn install_package_known_pyx_url_failure_shows_pyx_guidance() -> Result<()> {
let context = uv_test::test_context!("3.12");
let proxy = crate::pypi_proxy::start().await;
let netrc = context.temp_dir.child(".netrc");
netrc.write_str("machine example.com login public password heron")?;
let pyx_credentials_dir = context.temp_dir.child("pyx-credentials");
pyx_credentials_dir.create_dir_all()?;

uv_snapshot!(context.filters(), context.pip_install()
.arg("anyio")
.arg("--index-url")
.arg(proxy.url("/bearer-auth/simple"))
.env(EnvVars::NETRC, netrc.as_os_str())
.env(EnvVars::PYX_API_URL, proxy.uri())
.env(EnvVars::PYX_API_KEY, "invalid-api-key")
.env(EnvVars::PYX_CREDENTIALS_DIR, pyx_credentials_dir.as_os_str())
.arg("--strict"), @"
success: false
exit_code: 2
----- stdout -----

----- stderr -----
error: Failed to fetch: `http://[LOCALHOST]/bearer-auth/simple/anyio/`
Caused by: Run `uv auth login pyx.dev` to authenticate uv with pyx
"
);

Ok(())
}

/// Install a package from an index that requires authentication
/// Define the `--index-url` in the requirements file
#[tokio::test]
Expand Down
48 changes: 48 additions & 0 deletions crates/uv/tests/it/pypi_proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
//! | `/basic-auth/simple/{pkg}/` | `public:heron` | Simple API JSON, file URLs → `/basic-auth/files/…` |
//! | `/basic-auth/relative/simple/{pkg}/`| `public:heron` | Simple API JSON, file URLs are relative |
//! | `/basic-auth/files/…` | `public:heron` | 302 redirect → `files.pythonhosted.org` |
//! | `/bearer-auth/simple/{pkg}/` | Bearer token | Simple API JSON, file URLs → `/bearer-auth/files/…` |
//! | `/bearer-auth/files/…` | Bearer token | 302 redirect → `files.pythonhosted.org` |
//! | `/basic-auth-heron/simple/{pkg}/` | `public:heron` | Same as basic-auth but separate location |
//! | `/basic-auth-heron/files/…` | `public:heron` | 302 redirect → `files.pythonhosted.org` |
//! | `/basic-auth-eagle/simple/{pkg}/` | `public:eagle` | Same, different password |
Expand All @@ -24,6 +26,12 @@ use std::hash::{Hash, Hasher};

use serde_json::json;

const PYX_TEST_TOKEN: &str = "pyx-test-token";

pub(crate) fn pyx_test_token() -> &'static str {
PYX_TEST_TOKEN
}

/// Package metadata needed to build Simple API responses.
struct PackageEntry {
filename: &'static str,
Expand Down Expand Up @@ -326,6 +334,10 @@ pub(crate) async fn start() -> PypiProxy {
.headers
.get(&http::header::AUTHORIZATION)
.and_then(parse_basic_auth);
let bearer_auth = req
.headers
.get(&http::header::AUTHORIZATION)
.and_then(parse_bearer_auth);

// Route: /basic-auth/files/...
if let Some(rest) = path.strip_prefix("/basic-auth/files/") {
Expand All @@ -339,6 +351,18 @@ pub(crate) async fn start() -> PypiProxy {
return unauthorized_response();
}

// Route: /bearer-auth/files/...
if let Some(rest) = path.strip_prefix("/bearer-auth/files/") {
if bearer_auth
.as_ref()
.is_some_and(|token| token == PYX_TEST_TOKEN)
{
let target = format!("https://files.pythonhosted.org/{rest}");
return ResponseTemplate::new(302).insert_header("Location", target);
}
return unauthorized_response();
}

// Route: /basic-auth-heron/files/...
if let Some(rest) = path.strip_prefix("/basic-auth-heron/files/") {
if auth
Expand Down Expand Up @@ -400,6 +424,22 @@ pub(crate) async fn start() -> PypiProxy {
return unauthorized_response();
}

// Route: /bearer-auth/simple/{pkg}/
if let Some(pkg) = extract_package_name(path, "/bearer-auth/simple/") {
if bearer_auth
.as_ref()
.is_some_and(|token| token == PYX_TEST_TOKEN)
{
if let Some(entries) = db.get(pkg) {
let file_prefix = format!("{server_uri}/bearer-auth/files");
let body = build_simple_api_response(pkg, entries, &file_prefix);
return simple_api_response(&body);
}
return ResponseTemplate::new(404);
}
return unauthorized_response();
}

// Route: /basic-auth-heron/simple/{pkg}/
if let Some(pkg) = extract_package_name(path, "/basic-auth-heron/simple/") {
if auth
Expand Down Expand Up @@ -487,6 +527,14 @@ fn parse_basic_auth(value: &wiremock::http::HeaderValue) -> Option<(String, Stri
Some((user.to_string(), pass.to_string()))
}

/// Parse a `Bearer <token>` Authorization header.
fn parse_bearer_auth(value: &wiremock::http::HeaderValue) -> Option<String> {
let s = value.as_bytes();
let s = std::str::from_utf8(s).ok()?;
let token = s.strip_prefix("Bearer ")?;
Some(token.to_string())
}

fn unauthorized_response() -> wiremock::ResponseTemplate {
wiremock::ResponseTemplate::new(401)
.insert_header("WWW-Authenticate", r#"Basic realm="authenticated""#)
Expand Down
Loading