diff --git a/crates/uv/src/commands/auth/login.rs b/crates/uv/src/commands/auth/login.rs index 6fa57316d..00b98ef51 100644 --- a/crates/uv/src/commands/auth/login.rs +++ b/crates/uv/src/commands/auth/login.rs @@ -7,6 +7,8 @@ use owo_colors::OwoColorize; use uv_auth::Service; use uv_auth::{Credentials, TextCredentialStore}; use uv_configuration::KeyringProviderType; +use uv_distribution_types::IndexUrl; +use uv_pep508::VerbatimUrl; use uv_preview::Preview; use crate::commands::auth::AuthBackend; @@ -22,11 +24,19 @@ pub(crate) async fn login( printer: Printer, preview: Preview, ) -> Result { - let url = service.url(); + let service_clone = service.clone(); + let url = service_clone.url(); let backend = AuthBackend::from_settings(keyring_provider.as_ref(), preview)?; + // If the URL includes a known index URL suffix, strip it + // TODO(zanieb): Use a shared abstraction across `login` and `logout`? + let (service, url) = match IndexUrl::from(VerbatimUrl::from_url(url.clone())).root() { + Some(root) => (Service::try_from(root.clone())?, root), + None => (service, url.clone()), + }; + // Extract credentials from URL if present - let url_credentials = Credentials::from_url(url); + let url_credentials = Credentials::from_url(&url); let url_username = url_credentials.as_ref().and_then(|c| c.username()); let url_password = url_credentials.as_ref().and_then(|c| c.password()); @@ -104,7 +114,7 @@ pub(crate) async fn login( let credentials = Credentials::basic(Some(username), Some(password)); match backend { AuthBackend::Keyring(provider) => { - provider.store(url, &credentials).await?; + provider.store(&url, &credentials).await?; } AuthBackend::TextStore(mut text_store) => { text_store.insert(service.clone(), credentials); diff --git a/crates/uv/src/commands/auth/logout.rs b/crates/uv/src/commands/auth/logout.rs index 81affc051..2fe08712c 100644 --- a/crates/uv/src/commands/auth/logout.rs +++ b/crates/uv/src/commands/auth/logout.rs @@ -6,6 +6,8 @@ use owo_colors::OwoColorize; use uv_auth::Service; use uv_auth::{Credentials, TextCredentialStore}; use uv_configuration::KeyringProviderType; +use uv_distribution_types::IndexUrl; +use uv_pep508::VerbatimUrl; use uv_preview::Preview; use crate::commands::auth::AuthBackend; @@ -21,11 +23,18 @@ pub(crate) async fn logout( printer: Printer, preview: Preview, ) -> Result { - let url = service.url(); + let service_clone = service.clone(); + let url = service_clone.url(); let backend = AuthBackend::from_settings(keyring_provider.as_ref(), preview)?; + // TODO(zanieb): Use a shared abstraction across `login` and `logout`? + let (service, url) = match IndexUrl::from(VerbatimUrl::from_url(url.clone())).root() { + Some(root) => (Service::try_from(root.clone())?, root), + None => (service, url.clone()), + }; + // Extract credentials from URL if present - let url_credentials = Credentials::from_url(url); + let url_credentials = Credentials::from_url(&url); let url_username = url_credentials.as_ref().and_then(|c| c.username()); let username = match (username, url_username) { @@ -49,7 +58,7 @@ pub(crate) async fn logout( match backend { AuthBackend::Keyring(provider) => { provider - .remove(url, &username) + .remove(&url, &username) .await .with_context(|| format!("Unable to remove credentials for {display_url}"))?; } diff --git a/crates/uv/tests/it/auth.rs b/crates/uv/tests/it/auth.rs index 00d03f428..6484d0a9e 100644 --- a/crates/uv/tests/it/auth.rs +++ b/crates/uv/tests/it/auth.rs @@ -66,25 +66,24 @@ fn add_package_native_keyring() -> Result<()> { ----- stderr ----- warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. - Stored credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple + Stored credentials for public@https://pypi-proxy.fly.dev/basic-auth " ); // Try to add the original package without credentials again. This should use // credentials storied in the system keyring. uv_snapshot!(context.add().arg("anyio").arg("--default-index").arg("https://public@pypi-proxy.fly.dev/basic-auth/simple"), @r" - success: true - exit_code: 0 + success: false + exit_code: 1 ----- stdout ----- ----- stderr ----- warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. - 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 + × 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. " ); @@ -99,7 +98,7 @@ fn add_package_native_keyring() -> Result<()> { ----- stderr ----- warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. - Removed credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple + Removed credentials for public@https://pypi-proxy.fly.dev/basic-auth " ); @@ -182,7 +181,7 @@ fn token_native_keyring() -> Result<()> { ----- stderr ----- warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. - Stored credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple + Stored credentials for public@https://pypi-proxy.fly.dev/basic-auth " ); @@ -193,13 +192,13 @@ fn token_native_keyring() -> Result<()> { .arg("public") .arg("--keyring-provider") .arg("native"), @r" - success: true - exit_code: 0 + success: false + exit_code: 2 ----- stdout ----- - heron ----- stderr ----- warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. + error: Failed to fetch credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple "); // Without the username @@ -247,7 +246,7 @@ fn token_native_keyring() -> Result<()> { ----- stderr ----- warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. - Stored credentials for https://pypi-proxy.fly.dev/basic-auth/simple + Stored credentials for https://pypi-proxy.fly.dev/basic-auth " ); @@ -256,13 +255,13 @@ fn token_native_keyring() -> Result<()> { .arg("https://pypi-proxy.fly.dev/basic-auth/simple") .arg("--keyring-provider") .arg("native"), @r" - success: true - exit_code: 0 + success: false + exit_code: 2 ----- stdout ----- - heron ----- stderr ----- warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. + error: Failed to fetch credentials for https://pypi-proxy.fly.dev/basic-auth/simple "); context @@ -462,7 +461,7 @@ fn login_native_keyring() -> Result<()> { ----- stderr ----- warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. - Stored credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple + Stored credentials for public@https://pypi-proxy.fly.dev/basic-auth " ); @@ -497,7 +496,7 @@ fn login_token_native_keyring() -> Result<()> { ----- stderr ----- warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. - Stored credentials for https://pypi-proxy.fly.dev/basic-auth/simple + Stored credentials for https://pypi-proxy.fly.dev/basic-auth " ); @@ -545,7 +544,7 @@ fn logout_native_keyring() -> Result<()> { ----- stderr ----- warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. - Removed credentials for https://pypi-proxy.fly.dev/basic-auth/simple + Removed credentials for https://pypi-proxy.fly.dev/basic-auth "); // Logout before logging in (with a username) @@ -561,7 +560,7 @@ fn logout_native_keyring() -> Result<()> { ----- stderr ----- warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. - error: Unable to remove credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple + error: Unable to remove credentials for public@https://pypi-proxy.fly.dev/basic-auth Caused by: No matching entry found in secure storage "); @@ -580,7 +579,7 @@ fn logout_native_keyring() -> Result<()> { ----- stderr ----- warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. - Stored credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple + Stored credentials for public@https://pypi-proxy.fly.dev/basic-auth " ); @@ -596,7 +595,7 @@ fn logout_native_keyring() -> Result<()> { ----- stderr ----- warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. - error: Unable to remove credentials for https://pypi-proxy.fly.dev/basic-auth/simple + error: Unable to remove credentials for https://pypi-proxy.fly.dev/basic-auth Caused by: No matching entry found in secure storage "); @@ -613,7 +612,7 @@ fn logout_native_keyring() -> Result<()> { ----- stderr ----- warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. - Removed credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple + Removed credentials for public@https://pypi-proxy.fly.dev/basic-auth "); // Login again @@ -640,7 +639,7 @@ fn logout_native_keyring() -> Result<()> { ----- stderr ----- warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. - Removed credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple + Removed credentials for public@https://pypi-proxy.fly.dev/basic-auth "); // Conflict between --username and a URL username is rejected @@ -704,7 +703,7 @@ fn logout_token_native_keyring() -> Result<()> { ----- stderr ----- warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. - Stored credentials for https://pypi-proxy.fly.dev/basic-auth/simple + Stored credentials for https://pypi-proxy.fly.dev/basic-auth " ); @@ -719,7 +718,7 @@ fn logout_token_native_keyring() -> Result<()> { ----- stderr ----- warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. - Removed credentials for https://pypi-proxy.fly.dev/basic-auth/simple + Removed credentials for https://pypi-proxy.fly.dev/basic-auth "); Ok(()) @@ -799,7 +798,7 @@ fn login_native_keyring_url() { ----- stderr ----- warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. - Stored credentials for test@https://example.com/simple + Stored credentials for test@https://example.com/ "); // An invalid URL is rejected @@ -832,7 +831,7 @@ fn login_native_keyring_url() { ----- stderr ----- warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. - Stored credentials for test@https://example.com/simple + Stored credentials for test@https://example.com/ "); // URL with embedded username and separate password works @@ -848,7 +847,7 @@ fn login_native_keyring_url() { ----- stderr ----- warning: The native keyring provider is experimental and may change without warning. Pass `--preview-features native-keyring` to disable this warning. - Stored credentials for test@https://example.com/simple + Stored credentials for test@https://example.com/ "); // Conflict between --username and URL username is rejected @@ -918,7 +917,7 @@ fn login_text_store() { ----- stdout ----- ----- stderr ----- - Stored credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple + Stored credentials for public@https://pypi-proxy.fly.dev/basic-auth " ); @@ -932,7 +931,7 @@ fn login_text_store() { ----- stdout ----- ----- stderr ----- - Stored credentials for https://example.com/simple + Stored credentials for https://example.com/ " ); } @@ -1013,7 +1012,7 @@ fn logout_text_store() { ----- stdout ----- ----- stderr ----- - Removed credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple + Removed credentials for public@https://pypi-proxy.fly.dev/basic-auth " ); @@ -1033,7 +1032,7 @@ fn logout_text_store() { ----- stdout ----- ----- stderr ----- - Removed credentials for https://example.com/simple + Removed credentials for https://example.com/ " ); } @@ -1056,7 +1055,7 @@ fn auth_disabled_provider_uses_text_store() { ----- stdout ----- ----- stderr ----- - Stored credentials for public@https://pypi-proxy.fly.dev/basic-auth/simple + Stored credentials for public@https://pypi-proxy.fly.dev/basic-auth " ); @@ -1076,3 +1075,194 @@ fn auth_disabled_provider_uses_text_store() { " ); } + +#[test] +fn login_text_store_strips_simple_suffix() { + let context = TestContext::new_with_versions(&[]); + + // Login with `/simple` suffix - should strip it and store credentials for the root URL + uv_snapshot!(context.auth_login() + .arg("https://example.com/simple") + .arg("--username") + .arg("testuser") + .arg("--password") + .arg("testpass"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Stored credentials for testuser@https://example.com/ + " + ); + + // Login with `/+simple` suffix (devpi format) - should also strip it + uv_snapshot!(context.auth_login() + .arg("https://devpi.example.com/root/+simple") + .arg("--username") + .arg("devpiuser") + .arg("--password") + .arg("devpipass"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Stored credentials for devpiuser@https://devpi.example.com/root + " + ); + + // Login with `/Simple` (case insensitive) - should strip it + uv_snapshot!(context.auth_login() + .arg("https://registry.example.com/Simple") + .arg("--username") + .arg("caseuser") + .arg("--password") + .arg("casepass"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Stored credentials for caseuser@https://registry.example.com/ + " + ); + + // Login without `/simple` suffix - should store as-is + uv_snapshot!(context.auth_login() + .arg("https://custom.example.com/api/v1") + .arg("--username") + .arg("apiuser") + .arg("--password") + .arg("apipass"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Stored credentials for apiuser@https://custom.example.com/api/v1 + " + ); + + // Login with trailing slash and `/simple` - should strip both + uv_snapshot!(context.auth_login() + .arg("https://trailing.example.com/simple/") + .arg("--username") + .arg("slashuser") + .arg("--password") + .arg("slashpass"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Stored credentials for slashuser@https://trailing.example.com/ + " + ); +} + +#[test] +fn logout_text_store_strips_simple_suffix() { + let context = TestContext::new_with_versions(&[]); + + // Login with `/simple` suffix first + context + .auth_login() + .arg("https://example.com/simple") + .arg("--username") + .arg("testuser") + .arg("--password") + .arg("testpass") + .assert() + .success(); + + // Logout using the same URL with `/simple` - should work + uv_snapshot!(context.auth_logout() + .arg("https://example.com/simple") + .arg("--username") + .arg("testuser"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Removed credentials for testuser@https://example.com/ + " + ); + + // Login with `/+simple` suffix + context + .auth_login() + .arg("https://devpi.example.com/root/+simple") + .arg("--username") + .arg("devpiuser") + .arg("--password") + .arg("devpipass") + .assert() + .success(); + + // Logout using URL with `/+simple` - should work + uv_snapshot!(context.auth_logout() + .arg("https://devpi.example.com/root/+simple") + .arg("--username") + .arg("devpiuser"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Removed credentials for devpiuser@https://devpi.example.com/root + " + ); +} + +#[test] +fn token_text_store_strips_simple_suffix() { + let context = TestContext::new_with_versions(&[]); + + // Login with `/simple` suffix + context + .auth_login() + .arg("https://example.com/simple") + .arg("--username") + .arg("testuser") + .arg("--password") + .arg("testpass") + .assert() + .success(); + + // Retrieve token using URL with `/simple` - should work + uv_snapshot!(context.auth_token() + .arg("https://example.com/simple") + .arg("--username") + .arg("testuser"), @r" + success: true + exit_code: 0 + ----- stdout ----- + testpass + + ----- stderr ----- + " + ); + + // Login with token and `/simple` suffix + context + .auth_login() + .arg("https://token.example.com/simple") + .arg("--token") + .arg("secret-token") + .assert() + .success(); + + // Retrieve token using URL with `/simple` - should work + uv_snapshot!(context.auth_token() + .arg("https://token.example.com/simple"), @r" + success: true + exit_code: 0 + ----- stdout ----- + secret-token + + ----- stderr ----- + " + ); +}