Add a `uv auth helper --protocol bazel` command (#16886)

This commit is contained in:
Zsolt Dollenstein 2025-12-04 18:56:57 +00:00 committed by GitHub
parent ee6e3be815
commit 0c5391a7c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 460 additions and 15 deletions

View File

@ -4934,6 +4934,14 @@ pub enum AuthCommand {
/// Credentials are only stored in this directory when the plaintext backend is used, as
/// opposed to the native backend, which uses the system keyring.
Dir(AuthDirArgs),
/// Act as a credential helper for external tools.
///
/// Implements the Bazel credential helper protocol to provide credentials
/// to external tools via JSON over stdin/stdout.
///
/// This command is typically invoked by external tools.
#[command(hide = true)]
Helper(AuthHelperArgs),
}
#[derive(Args)]
@ -6215,6 +6223,30 @@ pub struct AuthDirArgs {
pub service: Option<Service>,
}
#[derive(Args)]
pub struct AuthHelperArgs {
#[command(subcommand)]
pub command: AuthHelperCommand,
/// The credential helper protocol to use
#[arg(long, value_enum, required = true)]
pub protocol: AuthHelperProtocol,
}
/// Credential helper protocols supported by uv
#[derive(Debug, Copy, Clone, PartialEq, Eq, clap::ValueEnum)]
pub enum AuthHelperProtocol {
/// Bazel credential helper protocol as described in [the
/// spec](https://github.com/bazelbuild/proposals/blob/main/designs/2022-06-07-bazel-credential-helpers.md)
Bazel,
}
#[derive(Subcommand)]
pub enum AuthHelperCommand {
/// Retrieve credentials for a URI
Get,
}
#[derive(Args)]
pub struct GenerateShellCompletionArgs {
/// The shell to generate the completion script for

View File

@ -26,6 +26,7 @@ bitflags::bitflags! {
const WORKSPACE_DIR = 1 << 14;
const WORKSPACE_LIST = 1 << 15;
const SBOM_EXPORT = 1 << 16;
const AUTH_HELPER = 1 << 17;
}
}
@ -52,6 +53,7 @@ impl PreviewFeatures {
Self::WORKSPACE_DIR => "workspace-dir",
Self::WORKSPACE_LIST => "workspace-list",
Self::SBOM_EXPORT => "sbom-export",
Self::AUTH_HELPER => "auth-helper",
_ => panic!("`flag_as_str` can only be used for exactly one feature flag"),
}
}
@ -106,6 +108,7 @@ impl FromStr for PreviewFeatures {
"workspace-dir" => Self::WORKSPACE_DIR,
"workspace-list" => Self::WORKSPACE_LIST,
"sbom-export" => Self::SBOM_EXPORT,
"auth-helper" => Self::AUTH_HELPER,
_ => {
warn_user_once!("Unknown preview feature: `{part}`");
continue;

View File

@ -0,0 +1,151 @@
use std::collections::HashMap;
use std::fmt::Write;
use std::io::Read;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use tracing::debug;
use uv_auth::{AuthBackend, Credentials, PyxTokenStore};
use uv_client::BaseClientBuilder;
use uv_preview::{Preview, PreviewFeatures};
use uv_redacted::DisplaySafeUrl;
use uv_warnings::warn_user;
use crate::{commands::ExitStatus, printer::Printer, settings::NetworkSettings};
/// Request format for the Bazel credential helper protocol.
#[derive(Debug, Deserialize)]
struct BazelCredentialRequest {
uri: DisplaySafeUrl,
}
impl BazelCredentialRequest {
fn from_str(s: &str) -> Result<Self> {
serde_json::from_str(s).context("Failed to parse credential request as JSON")
}
fn from_stdin() -> Result<Self> {
let mut buffer = String::new();
std::io::stdin()
.read_to_string(&mut buffer)
.context("Failed to read from stdin")?;
Self::from_str(&buffer)
}
}
/// Response format for the Bazel credential helper protocol.
#[derive(Debug, Serialize, Default)]
struct BazelCredentialResponse {
headers: HashMap<String, Vec<String>>,
}
impl TryFrom<Credentials> for BazelCredentialResponse {
fn try_from(creds: Credentials) -> Result<Self> {
let header_str = creds
.to_header_value()
.to_str()
// TODO: this is infallible in practice
.context("Failed to convert header value to string")?
.to_owned();
Ok(Self {
headers: HashMap::from([("Authorization".to_owned(), vec![header_str])]),
})
}
type Error = anyhow::Error;
}
async fn credentials_for_url(
url: &DisplaySafeUrl,
preview: Preview,
network_settings: &NetworkSettings,
) -> Result<Option<Credentials>> {
let pyx_store = PyxTokenStore::from_settings()?;
// Use only the username from the URL, if present - discarding the password
let url_credentials = Credentials::from_url(url);
let username = url_credentials.as_ref().and_then(|c| c.username());
if url_credentials
.as_ref()
.map(|c| c.password().is_some())
.unwrap_or(false)
{
debug!("URL '{url}' contain a password; ignoring");
}
if pyx_store.is_known_domain(url) {
if username.is_some() {
bail!(
"Cannot specify a username for URLs under {}",
url.host()
.map(|host| host.to_string())
.unwrap_or(url.to_string())
);
}
let client = BaseClientBuilder::new(
network_settings.connectivity,
network_settings.native_tls,
network_settings.allow_insecure_host.clone(),
preview,
network_settings.timeout,
network_settings.retries,
)
.auth_integration(uv_client::AuthIntegration::NoAuthMiddleware)
.build();
let token = pyx_store
.access_token(client.for_host(pyx_store.api()).raw_client(), 0)
.await
.context("Authentication failure")?
.context("No access token found")?;
return Ok(Some(Credentials::bearer(token.into_bytes())));
}
let backend = AuthBackend::from_settings(preview).await?;
let credentials = match &backend {
AuthBackend::System(provider) => provider.fetch(url, username).await,
AuthBackend::TextStore(store, _lock) => store.get_credentials(url, username).cloned(),
};
Ok(credentials)
}
/// Implement the Bazel credential helper protocol.
///
/// Reads a JSON request from stdin containing a URI, looks up credentials
/// for that URI using uv's authentication backends, and writes a JSON response
/// to stdout containing HTTP headers (if credentials are found).
///
/// Protocol specification TLDR:
/// - Input (stdin): `{"uri": "https://example.com/path"}`
/// - Output (stdout): `{"headers": {"Authorization": ["Basic ..."]}}` or `{"headers": {}}`
/// - Errors: Written to stderr with non-zero exit code
///
/// Full spec is [available here](https://github.com/bazelbuild/proposals/blob/main/designs/2022-06-07-bazel-credential-helpers.md)
pub(crate) async fn helper(
preview: Preview,
network_settings: &NetworkSettings,
printer: Printer,
) -> Result<ExitStatus> {
if !preview.is_enabled(PreviewFeatures::AUTH_HELPER) {
warn_user!(
"The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features {}` to disable this warning",
PreviewFeatures::AUTH_HELPER
);
}
let request = BazelCredentialRequest::from_stdin()?;
// TODO: make this logic generic over the protocol by providing `request.uri` from a
// trait - that should help with adding new protocols
let credentials = credentials_for_url(&request.uri, preview, network_settings).await?;
let response = serde_json::to_string(
&credentials
.map(BazelCredentialResponse::try_from)
.unwrap_or_else(|| Ok(BazelCredentialResponse::default()))?,
)
.context("Failed to serialize response as JSON")?;
writeln!(printer.stdout_important(), "{response}")?;
Ok(ExitStatus::Success)
}

View File

@ -1,4 +1,5 @@
pub(crate) mod dir;
pub(crate) mod helper;
pub(crate) mod login;
pub(crate) mod logout;
pub(crate) mod token;

View File

@ -10,6 +10,7 @@ use owo_colors::OwoColorize;
use tracing::debug;
pub(crate) use auth::dir::dir as auth_dir;
pub(crate) use auth::helper::helper as auth_helper;
pub(crate) use auth::login::login as auth_login;
pub(crate) use auth::logout::logout as auth_logout;
pub(crate) use auth::token::token as auth_token;

View File

@ -28,10 +28,10 @@ use uv_cache_info::Timestamp;
#[cfg(feature = "self-update")]
use uv_cli::SelfUpdateArgs;
use uv_cli::{
AuthCommand, AuthNamespace, BuildBackendCommand, CacheCommand, CacheNamespace, Cli, Commands,
PipCommand, PipNamespace, ProjectCommand, PythonCommand, PythonNamespace, SelfCommand,
SelfNamespace, ToolCommand, ToolNamespace, TopLevelArgs, WorkspaceCommand, WorkspaceNamespace,
compat::CompatArgs,
AuthCommand, AuthHelperCommand, AuthNamespace, BuildBackendCommand, CacheCommand,
CacheNamespace, Cli, Commands, PipCommand, PipNamespace, ProjectCommand, PythonCommand,
PythonNamespace, SelfCommand, SelfNamespace, ToolCommand, ToolNamespace, TopLevelArgs,
WorkspaceCommand, WorkspaceNamespace, compat::CompatArgs,
};
use uv_client::BaseClientBuilder;
use uv_configuration::min_stack_size;
@ -546,6 +546,22 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
commands::auth_dir(args.service.as_ref(), printer)?;
Ok(ExitStatus::Success)
}
Commands::Auth(AuthNamespace {
command: AuthCommand::Helper(args),
}) => {
use uv_cli::AuthHelperProtocol;
// Validate protocol (currently only Bazel is supported)
match args.protocol {
AuthHelperProtocol::Bazel => {}
}
match args.command {
AuthHelperCommand::Get => {
commands::auth_helper(globals.preview, &globals.network_settings, printer).await
}
}
}
Commands::Help(args) => commands::help(
args.command.unwrap_or_default().as_slice(),
printer,

View File

@ -1925,3 +1925,205 @@ fn native_auth_host_fallback() -> Result<()> {
Ok(())
}
/// Test credential helper with basic auth credentials
#[test]
fn bazel_helper_basic_auth() {
let context = TestContext::new("3.12");
// Store credentials
uv_snapshot!(context.filters(), context.auth_login()
.arg("https://test.example.com")
.arg("--username").arg("testuser")
.arg("--password").arg("testpass"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Stored credentials for testuser@https://test.example.com/
"###);
uv_snapshot!(context.filters(), context.auth_helper()
.arg("--protocol=bazel")
.arg("get"),
input=r#"{"uri":"https://test.example.com/path"}"#,
@r#"
success: true
exit_code: 0
----- stdout -----
{"headers":{"Authorization":["Basic dGVzdHVzZXI6dGVzdHBhc3M="]}}
----- stderr -----
warning: The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features auth-helper` to disable this warning
"#
);
}
/// Test credential helper with token credentials
#[test]
fn bazel_helper_token() {
let context = TestContext::new("3.12");
// Store token
uv_snapshot!(context.filters(), context.auth_login()
.arg("https://api.example.com")
.arg("--token").arg("mytoken123"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Stored credentials for https://api.example.com/
"###);
// Test credential helper - tokens are stored as Basic auth with __token__ username
uv_snapshot!(context.filters(), context.auth_helper()
.arg("--protocol=bazel")
.arg("get"),
input=r#"{"uri":"https://api.example.com/v1/endpoint"}"#,
@r#"
success: true
exit_code: 0
----- stdout -----
{"headers":{"Authorization":["Basic X190b2tlbl9fOm15dG9rZW4xMjM="]}}
----- stderr -----
warning: The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features auth-helper` to disable this warning
"#
);
}
/// Test credential helper with no credentials found
#[test]
fn bazel_helper_no_credentials() {
let context = TestContext::new("3.12");
uv_snapshot!(context.filters(), context.auth_helper()
.arg("--protocol=bazel")
.arg("get"),
input=r#"{"uri":"https://unknown.example.com/path"}"#,
@r#"
success: true
exit_code: 0
----- stdout -----
{"headers":{}}
----- stderr -----
warning: The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features auth-helper` to disable this warning
"#
);
}
/// Test credential helper with invalid JSON input
#[test]
fn bazel_helper_invalid_json() {
let context = TestContext::new("3.12");
uv_snapshot!(context.filters(), context.auth_helper()
.arg("--protocol=bazel")
.arg("get"),
input="not json",
@r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
warning: The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features auth-helper` to disable this warning
error: Failed to parse credential request as JSON
Caused by: expected ident at line 1 column 2
"
);
}
/// Test credential helper with invalid URI
#[test]
fn bazel_helper_invalid_uri() {
let context = TestContext::new("3.12");
uv_snapshot!(context.filters(), context.auth_helper()
.arg("--protocol=bazel")
.arg("get"),
input=r#"{"uri":"not a url"}"#,
@r#"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
warning: The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features auth-helper` to disable this warning
error: Failed to parse credential request as JSON
Caused by: relative URL without a base: "not a url" at line 1 column 18
"#
);
}
/// Test credential helper with username in URI
#[test]
fn bazel_helper_username_in_uri() {
let context = TestContext::new("3.12");
// Store credentials with specific username
uv_snapshot!(context.filters(), context.auth_login()
.arg("https://test.example.com")
.arg("--username").arg("specificuser")
.arg("--password").arg("specificpass"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Stored credentials for specificuser@https://test.example.com/
"###);
// Test with username in URI
uv_snapshot!(context.filters(), context.auth_helper()
.arg("--protocol=bazel")
.arg("get"),
input=r#"{"uri":"https://specificuser@test.example.com/path"}"#,
@r#"
success: true
exit_code: 0
----- stdout -----
{"headers":{"Authorization":["Basic c3BlY2lmaWN1c2VyOnNwZWNpZmljcGFzcw=="]}}
----- stderr -----
warning: The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features auth-helper` to disable this warning
"#
);
}
/// Test credential helper with unknown username in URI
#[test]
fn bazel_helper_unknown_username_in_uri() {
let context = TestContext::new("3.12");
// Store credentials with specific username
uv_snapshot!(context.filters(), context.auth_login()
.arg("https://test.example.com")
.arg("--username").arg("specificuser")
.arg("--password").arg("specificpass"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Stored credentials for specificuser@https://test.example.com/
"###);
// Test with username in URI
uv_snapshot!(context.filters(), context.auth_helper()
.arg("--protocol=bazel")
.arg("get"),
input=r#"{"uri":"https://differentuser@test.example.com/path"}"#,
@r#"
success: true
exit_code: 0
----- stdout -----
{"headers":{}}
----- stderr -----
warning: The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features auth-helper` to disable this warning
"#
);
}

View File

@ -3,9 +3,10 @@
use std::borrow::BorrowMut;
use std::ffi::OsString;
use std::io::Write as _;
use std::iter::Iterator;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Output};
use std::process::{Command, ExitStatus, Output, Stdio};
use std::str::FromStr;
use std::{env, io};
use uv_python::downloads::ManagedPythonDownloadList;
@ -1457,6 +1458,14 @@ impl TestContext {
command
}
/// Create a `uv auth helper --protocol bazel get` command.
pub fn auth_helper(&self) -> Command {
let mut command = Self::new_command();
command.arg("auth").arg("helper");
self.add_shared_options(&mut command, false);
command
}
/// Create a `uv auth token` command.
pub fn auth_token(&self) -> Command {
let mut command = Self::new_command();
@ -1656,6 +1665,7 @@ impl TestContext {
self.filters(),
"diff_lock",
Some(WindowsFilters::Platform),
None,
);
assert!(status.success(), "{snapshot}");
let new_lock = fs_err::read_to_string(&lock_path).unwrap();
@ -1839,9 +1849,10 @@ pub fn run_and_format<T: AsRef<str>>(
filters: impl AsRef<[(T, T)]>,
function_name: &str,
windows_filters: Option<WindowsFilters>,
input: Option<&str>,
) -> (String, Output) {
let (snapshot, output, _) =
run_and_format_with_status(command, filters, function_name, windows_filters);
run_and_format_with_status(command, filters, function_name, windows_filters, input);
(snapshot, output)
}
@ -1854,6 +1865,7 @@ pub fn run_and_format_with_status<T: AsRef<str>>(
filters: impl AsRef<[(T, T)]>,
function_name: &str,
windows_filters: Option<WindowsFilters>,
input: Option<&str>,
) -> (String, Output, ExitStatus) {
let program = command
.borrow_mut()
@ -1873,10 +1885,30 @@ pub fn run_and_format_with_status<T: AsRef<str>>(
);
}
let output = command
let output = if let Some(input) = input {
let mut child = command
.borrow_mut()
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|err| panic!("Failed to spawn {program}: {err}"));
child
.stdin
.as_mut()
.expect("Failed to open stdin")
.write_all(input.as_bytes())
.expect("Failed to write to stdin");
child
.wait_with_output()
.unwrap_or_else(|err| panic!("Failed to read output from {program}: {err}"))
} else {
command
.borrow_mut()
.output()
.unwrap_or_else(|err| panic!("Failed to spawn {program}: {err}"));
.unwrap_or_else(|err| panic!("Failed to spawn {program}: {err}"))
};
eprintln!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Unfiltered output ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
eprintln!(
@ -2075,19 +2107,25 @@ macro_rules! uv_snapshot {
}};
($filters:expr, $spawnable:expr, @$snapshot:literal) => {{
// Take a reference for backwards compatibility with the vec-expecting insta filters.
let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, $crate::function_name!(), Some($crate::common::WindowsFilters::Platform));
let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, $crate::function_name!(), Some($crate::common::WindowsFilters::Platform), None);
::insta::assert_snapshot!(snapshot, @$snapshot);
output
}};
($filters:expr, $spawnable:expr, input=$input:expr, @$snapshot:literal) => {{
// Take a reference for backwards compatibility with the vec-expecting insta filters.
let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, $crate::function_name!(), Some($crate::common::WindowsFilters::Platform), Some($input));
::insta::assert_snapshot!(snapshot, @$snapshot);
output
}};
($filters:expr, windows_filters=false, $spawnable:expr, @$snapshot:literal) => {{
// Take a reference for backwards compatibility with the vec-expecting insta filters.
let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, $crate::function_name!(), None);
let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, $crate::function_name!(), None, None);
::insta::assert_snapshot!(snapshot, @$snapshot);
output
}};
($filters:expr, universal_windows_filters=true, $spawnable:expr, @$snapshot:literal) => {{
// Take a reference for backwards compatibility with the vec-expecting insta filters.
let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, $crate::function_name!(), Some($crate::common::WindowsFilters::Universal));
let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, $crate::function_name!(), Some($crate::common::WindowsFilters::Universal), None);
::insta::assert_snapshot!(snapshot, @$snapshot);
output
}};

View File

@ -110,6 +110,7 @@ fn lock_ecosystem_package(python_version: &str, name: &str) -> Result<()> {
context.filters(),
name,
Some(common::WindowsFilters::Platform),
None,
);
let lock = context.read("uv.lock");

View File

@ -7832,7 +7832,7 @@ fn preview_features() {
show_settings: true,
preview: Preview {
flags: PreviewFeatures(
PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR | WORKSPACE_LIST | SBOM_EXPORT,
PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR | WORKSPACE_LIST | SBOM_EXPORT | AUTH_HELPER,
),
},
python_preference: Managed,
@ -8060,7 +8060,7 @@ fn preview_features() {
show_settings: true,
preview: Preview {
flags: PreviewFeatures(
PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR | WORKSPACE_LIST | SBOM_EXPORT,
PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR | WORKSPACE_LIST | SBOM_EXPORT | AUTH_HELPER,
),
},
python_preference: Managed,