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
32 changes: 32 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions crates/uv-preview/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand All @@ -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"),
}
}
Expand Down Expand Up @@ -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;
Expand Down
151 changes: 151 additions & 0 deletions crates/uv/src/commands/auth/helper.rs
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions crates/uv/src/commands/auth/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub(crate) mod dir;
pub(crate) mod helper;
pub(crate) mod login;
pub(crate) mod logout;
pub(crate) mod token;
1 change: 1 addition & 0 deletions crates/uv/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 20 additions & 4 deletions crates/uv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,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;
Expand Down Expand Up @@ -542,6 +542,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,
Expand Down
Loading
Loading