Skip to content

Commit 0c5391a

Browse files
authored
Add a uv auth helper --protocol bazel command (#16886)
1 parent ee6e3be commit 0c5391a

File tree

10 files changed

+460
-15
lines changed

10 files changed

+460
-15
lines changed

crates/uv-cli/src/lib.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4934,6 +4934,14 @@ pub enum AuthCommand {
49344934
/// Credentials are only stored in this directory when the plaintext backend is used, as
49354935
/// opposed to the native backend, which uses the system keyring.
49364936
Dir(AuthDirArgs),
4937+
/// Act as a credential helper for external tools.
4938+
///
4939+
/// Implements the Bazel credential helper protocol to provide credentials
4940+
/// to external tools via JSON over stdin/stdout.
4941+
///
4942+
/// This command is typically invoked by external tools.
4943+
#[command(hide = true)]
4944+
Helper(AuthHelperArgs),
49374945
}
49384946

49394947
#[derive(Args)]
@@ -6215,6 +6223,30 @@ pub struct AuthDirArgs {
62156223
pub service: Option<Service>,
62166224
}
62176225

6226+
#[derive(Args)]
6227+
pub struct AuthHelperArgs {
6228+
#[command(subcommand)]
6229+
pub command: AuthHelperCommand,
6230+
6231+
/// The credential helper protocol to use
6232+
#[arg(long, value_enum, required = true)]
6233+
pub protocol: AuthHelperProtocol,
6234+
}
6235+
6236+
/// Credential helper protocols supported by uv
6237+
#[derive(Debug, Copy, Clone, PartialEq, Eq, clap::ValueEnum)]
6238+
pub enum AuthHelperProtocol {
6239+
/// Bazel credential helper protocol as described in [the
6240+
/// spec](https://github.com/bazelbuild/proposals/blob/main/designs/2022-06-07-bazel-credential-helpers.md)
6241+
Bazel,
6242+
}
6243+
6244+
#[derive(Subcommand)]
6245+
pub enum AuthHelperCommand {
6246+
/// Retrieve credentials for a URI
6247+
Get,
6248+
}
6249+
62186250
#[derive(Args)]
62196251
pub struct GenerateShellCompletionArgs {
62206252
/// The shell to generate the completion script for

crates/uv-preview/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ bitflags::bitflags! {
2626
const WORKSPACE_DIR = 1 << 14;
2727
const WORKSPACE_LIST = 1 << 15;
2828
const SBOM_EXPORT = 1 << 16;
29+
const AUTH_HELPER = 1 << 17;
2930
}
3031
}
3132

@@ -52,6 +53,7 @@ impl PreviewFeatures {
5253
Self::WORKSPACE_DIR => "workspace-dir",
5354
Self::WORKSPACE_LIST => "workspace-list",
5455
Self::SBOM_EXPORT => "sbom-export",
56+
Self::AUTH_HELPER => "auth-helper",
5557
_ => panic!("`flag_as_str` can only be used for exactly one feature flag"),
5658
}
5759
}
@@ -106,6 +108,7 @@ impl FromStr for PreviewFeatures {
106108
"workspace-dir" => Self::WORKSPACE_DIR,
107109
"workspace-list" => Self::WORKSPACE_LIST,
108110
"sbom-export" => Self::SBOM_EXPORT,
111+
"auth-helper" => Self::AUTH_HELPER,
109112
_ => {
110113
warn_user_once!("Unknown preview feature: `{part}`");
111114
continue;
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
use std::collections::HashMap;
2+
use std::fmt::Write;
3+
use std::io::Read;
4+
5+
use anyhow::{Context, Result, bail};
6+
use serde::{Deserialize, Serialize};
7+
use tracing::debug;
8+
9+
use uv_auth::{AuthBackend, Credentials, PyxTokenStore};
10+
use uv_client::BaseClientBuilder;
11+
use uv_preview::{Preview, PreviewFeatures};
12+
use uv_redacted::DisplaySafeUrl;
13+
use uv_warnings::warn_user;
14+
15+
use crate::{commands::ExitStatus, printer::Printer, settings::NetworkSettings};
16+
17+
/// Request format for the Bazel credential helper protocol.
18+
#[derive(Debug, Deserialize)]
19+
struct BazelCredentialRequest {
20+
uri: DisplaySafeUrl,
21+
}
22+
23+
impl BazelCredentialRequest {
24+
fn from_str(s: &str) -> Result<Self> {
25+
serde_json::from_str(s).context("Failed to parse credential request as JSON")
26+
}
27+
28+
fn from_stdin() -> Result<Self> {
29+
let mut buffer = String::new();
30+
std::io::stdin()
31+
.read_to_string(&mut buffer)
32+
.context("Failed to read from stdin")?;
33+
34+
Self::from_str(&buffer)
35+
}
36+
}
37+
38+
/// Response format for the Bazel credential helper protocol.
39+
#[derive(Debug, Serialize, Default)]
40+
struct BazelCredentialResponse {
41+
headers: HashMap<String, Vec<String>>,
42+
}
43+
44+
impl TryFrom<Credentials> for BazelCredentialResponse {
45+
fn try_from(creds: Credentials) -> Result<Self> {
46+
let header_str = creds
47+
.to_header_value()
48+
.to_str()
49+
// TODO: this is infallible in practice
50+
.context("Failed to convert header value to string")?
51+
.to_owned();
52+
53+
Ok(Self {
54+
headers: HashMap::from([("Authorization".to_owned(), vec![header_str])]),
55+
})
56+
}
57+
58+
type Error = anyhow::Error;
59+
}
60+
61+
async fn credentials_for_url(
62+
url: &DisplaySafeUrl,
63+
preview: Preview,
64+
network_settings: &NetworkSettings,
65+
) -> Result<Option<Credentials>> {
66+
let pyx_store = PyxTokenStore::from_settings()?;
67+
68+
// Use only the username from the URL, if present - discarding the password
69+
let url_credentials = Credentials::from_url(url);
70+
let username = url_credentials.as_ref().and_then(|c| c.username());
71+
if url_credentials
72+
.as_ref()
73+
.map(|c| c.password().is_some())
74+
.unwrap_or(false)
75+
{
76+
debug!("URL '{url}' contain a password; ignoring");
77+
}
78+
79+
if pyx_store.is_known_domain(url) {
80+
if username.is_some() {
81+
bail!(
82+
"Cannot specify a username for URLs under {}",
83+
url.host()
84+
.map(|host| host.to_string())
85+
.unwrap_or(url.to_string())
86+
);
87+
}
88+
let client = BaseClientBuilder::new(
89+
network_settings.connectivity,
90+
network_settings.native_tls,
91+
network_settings.allow_insecure_host.clone(),
92+
preview,
93+
network_settings.timeout,
94+
network_settings.retries,
95+
)
96+
.auth_integration(uv_client::AuthIntegration::NoAuthMiddleware)
97+
.build();
98+
let token = pyx_store
99+
.access_token(client.for_host(pyx_store.api()).raw_client(), 0)
100+
.await
101+
.context("Authentication failure")?
102+
.context("No access token found")?;
103+
return Ok(Some(Credentials::bearer(token.into_bytes())));
104+
}
105+
let backend = AuthBackend::from_settings(preview).await?;
106+
let credentials = match &backend {
107+
AuthBackend::System(provider) => provider.fetch(url, username).await,
108+
AuthBackend::TextStore(store, _lock) => store.get_credentials(url, username).cloned(),
109+
};
110+
Ok(credentials)
111+
}
112+
113+
/// Implement the Bazel credential helper protocol.
114+
///
115+
/// Reads a JSON request from stdin containing a URI, looks up credentials
116+
/// for that URI using uv's authentication backends, and writes a JSON response
117+
/// to stdout containing HTTP headers (if credentials are found).
118+
///
119+
/// Protocol specification TLDR:
120+
/// - Input (stdin): `{"uri": "https://example.com/path"}`
121+
/// - Output (stdout): `{"headers": {"Authorization": ["Basic ..."]}}` or `{"headers": {}}`
122+
/// - Errors: Written to stderr with non-zero exit code
123+
///
124+
/// Full spec is [available here](https://github.com/bazelbuild/proposals/blob/main/designs/2022-06-07-bazel-credential-helpers.md)
125+
pub(crate) async fn helper(
126+
preview: Preview,
127+
network_settings: &NetworkSettings,
128+
printer: Printer,
129+
) -> Result<ExitStatus> {
130+
if !preview.is_enabled(PreviewFeatures::AUTH_HELPER) {
131+
warn_user!(
132+
"The `uv auth helper` command is experimental and may change without warning. Pass `--preview-features {}` to disable this warning",
133+
PreviewFeatures::AUTH_HELPER
134+
);
135+
}
136+
137+
let request = BazelCredentialRequest::from_stdin()?;
138+
139+
// TODO: make this logic generic over the protocol by providing `request.uri` from a
140+
// trait - that should help with adding new protocols
141+
let credentials = credentials_for_url(&request.uri, preview, network_settings).await?;
142+
143+
let response = serde_json::to_string(
144+
&credentials
145+
.map(BazelCredentialResponse::try_from)
146+
.unwrap_or_else(|| Ok(BazelCredentialResponse::default()))?,
147+
)
148+
.context("Failed to serialize response as JSON")?;
149+
writeln!(printer.stdout_important(), "{response}")?;
150+
Ok(ExitStatus::Success)
151+
}

crates/uv/src/commands/auth/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub(crate) mod dir;
2+
pub(crate) mod helper;
23
pub(crate) mod login;
34
pub(crate) mod logout;
45
pub(crate) mod token;

crates/uv/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use owo_colors::OwoColorize;
1010
use tracing::debug;
1111

1212
pub(crate) use auth::dir::dir as auth_dir;
13+
pub(crate) use auth::helper::helper as auth_helper;
1314
pub(crate) use auth::login::login as auth_login;
1415
pub(crate) use auth::logout::logout as auth_logout;
1516
pub(crate) use auth::token::token as auth_token;

crates/uv/src/lib.rs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ use uv_cache_info::Timestamp;
2828
#[cfg(feature = "self-update")]
2929
use uv_cli::SelfUpdateArgs;
3030
use uv_cli::{
31-
AuthCommand, AuthNamespace, BuildBackendCommand, CacheCommand, CacheNamespace, Cli, Commands,
32-
PipCommand, PipNamespace, ProjectCommand, PythonCommand, PythonNamespace, SelfCommand,
33-
SelfNamespace, ToolCommand, ToolNamespace, TopLevelArgs, WorkspaceCommand, WorkspaceNamespace,
34-
compat::CompatArgs,
31+
AuthCommand, AuthHelperCommand, AuthNamespace, BuildBackendCommand, CacheCommand,
32+
CacheNamespace, Cli, Commands, PipCommand, PipNamespace, ProjectCommand, PythonCommand,
33+
PythonNamespace, SelfCommand, SelfNamespace, ToolCommand, ToolNamespace, TopLevelArgs,
34+
WorkspaceCommand, WorkspaceNamespace, compat::CompatArgs,
3535
};
3636
use uv_client::BaseClientBuilder;
3737
use uv_configuration::min_stack_size;
@@ -546,6 +546,22 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
546546
commands::auth_dir(args.service.as_ref(), printer)?;
547547
Ok(ExitStatus::Success)
548548
}
549+
Commands::Auth(AuthNamespace {
550+
command: AuthCommand::Helper(args),
551+
}) => {
552+
use uv_cli::AuthHelperProtocol;
553+
554+
// Validate protocol (currently only Bazel is supported)
555+
match args.protocol {
556+
AuthHelperProtocol::Bazel => {}
557+
}
558+
559+
match args.command {
560+
AuthHelperCommand::Get => {
561+
commands::auth_helper(globals.preview, &globals.network_settings, printer).await
562+
}
563+
}
564+
}
549565
Commands::Help(args) => commands::help(
550566
args.command.unwrap_or_default().as_slice(),
551567
printer,

0 commit comments

Comments
 (0)