mirror of
https://github.com/astral-sh/uv
synced 2026-01-22 14:00:11 -05:00
Abstract Trusted Publishing services (#17418)
This commit is contained in:
@@ -39,6 +39,7 @@ use uv_pypi_types::{HashAlgorithm, HashDigest, Metadata23, MetadataError};
|
||||
use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError};
|
||||
use uv_warnings::warn_user;
|
||||
|
||||
use crate::trusted_publishing::pypi::PyPIPublishingService;
|
||||
use crate::trusted_publishing::{TrustedPublishingError, TrustedPublishingToken};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
@@ -417,9 +418,8 @@ pub async fn check_trusted_publishing(
|
||||
|
||||
debug!("Attempting to get a token for trusted publishing");
|
||||
// Attempt to get a token for trusted publishing.
|
||||
match trusted_publishing::get_token(registry, client.for_host(registry).raw_client())
|
||||
.await
|
||||
{
|
||||
let service = PyPIPublishingService::new(registry, client);
|
||||
match trusted_publishing::get_token(&service).await {
|
||||
// Success: we have a token for trusted publishing.
|
||||
Ok(Some(token)) => Ok(TrustedPublishResult::Configured(token)),
|
||||
// Failed to discover an ambient OIDC token.
|
||||
@@ -447,10 +447,10 @@ pub async fn check_trusted_publishing(
|
||||
return Err(PublishError::MixedCredentials(conflicts.join(" and ")));
|
||||
}
|
||||
|
||||
let Some(token) =
|
||||
trusted_publishing::get_token(registry, client.for_host(registry).raw_client())
|
||||
.await
|
||||
.map_err(Box::new)?
|
||||
let service = PyPIPublishingService::new(registry, client);
|
||||
let Some(token) = trusted_publishing::get_token(&service)
|
||||
.await
|
||||
.map_err(Box::new)?
|
||||
else {
|
||||
return Err(PublishError::TrustedPublishing(
|
||||
TrustedPublishingError::NoToken.into(),
|
||||
|
||||
@@ -8,11 +8,11 @@ use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
use std::fmt::Display;
|
||||
use thiserror::Error;
|
||||
use tracing::{debug, trace};
|
||||
use url::Url;
|
||||
use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError};
|
||||
use uv_static::EnvVars;
|
||||
|
||||
pub(crate) mod pypi;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TrustedPublishingError {
|
||||
#[error(transparent)]
|
||||
@@ -83,6 +83,21 @@ pub struct OidcTokenClaims {
|
||||
r#ref: String,
|
||||
}
|
||||
|
||||
/// A service (i.e. uploadable index) that supports trusted publishing.
|
||||
pub(crate) trait TrustedPublishingService {
|
||||
/// Borrow the HTTP client with middleware.
|
||||
fn client(&self) -> &ClientWithMiddleware;
|
||||
|
||||
/// Retrieve the service's expected OIDC audience.
|
||||
async fn audience(&self) -> Result<String, TrustedPublishingError>;
|
||||
|
||||
/// Exchange an ambient OIDC identity token for a short-lived upload token on the service.
|
||||
async fn publish_token(
|
||||
&self,
|
||||
oidc_token: ambient_id::IdToken,
|
||||
) -> Result<TrustedPublishingToken, TrustedPublishingError>;
|
||||
}
|
||||
|
||||
/// Returns the short-lived token to use for uploading.
|
||||
///
|
||||
/// Return states:
|
||||
@@ -90,21 +105,20 @@ pub struct OidcTokenClaims {
|
||||
/// - `Ok(None)`: Not in a supported CI environment for trusted publishing.
|
||||
/// - `Err(...)`: An error occurred while trying to obtain the token.
|
||||
pub(crate) async fn get_token(
|
||||
registry: &DisplaySafeUrl,
|
||||
client: &ClientWithMiddleware,
|
||||
service: &impl TrustedPublishingService,
|
||||
) -> Result<Option<TrustedPublishingToken>, TrustedPublishingError> {
|
||||
// Get the OIDC token's audience from the registry.
|
||||
let audience = get_audience(registry, client).await?;
|
||||
let audience = service.audience().await?;
|
||||
|
||||
// Perform ambient OIDC token discovery.
|
||||
// Depending on the host (GitHub Actions, GitLab CI, etc.)
|
||||
// this may perform additional network requests.
|
||||
let oidc_token = get_oidc_token(&audience, client).await?;
|
||||
let oidc_token = get_oidc_token(&audience, service.client()).await?;
|
||||
|
||||
// Exchange the OIDC token for a short-lived upload token,
|
||||
// if OIDC token discovery succeeded.
|
||||
if let Some(oidc_token) = oidc_token {
|
||||
let publish_token = get_publish_token(registry, oidc_token, client).await?;
|
||||
let publish_token = service.publish_token(oidc_token).await?;
|
||||
|
||||
// If we're on GitHub Actions, mask the exchanged token in logs.
|
||||
#[allow(clippy::print_stdout)]
|
||||
@@ -119,39 +133,6 @@ pub(crate) async fn get_token(
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_audience(
|
||||
registry: &DisplaySafeUrl,
|
||||
client: &ClientWithMiddleware,
|
||||
) -> Result<String, TrustedPublishingError> {
|
||||
// `pypa/gh-action-pypi-publish` uses `netloc` (RFC 1808), which is deprecated for authority
|
||||
// (RFC 3986).
|
||||
// Prefer HTTPS for OIDC discovery; allow HTTP only in test builds
|
||||
let scheme: &str = if cfg!(feature = "test") {
|
||||
registry.scheme()
|
||||
} else {
|
||||
"https"
|
||||
};
|
||||
let audience_url = DisplaySafeUrl::parse(&format!(
|
||||
"{}://{}/_/oidc/audience",
|
||||
scheme,
|
||||
registry.authority()
|
||||
))?;
|
||||
debug!("Querying the trusted publishing audience from {audience_url}");
|
||||
let response = client
|
||||
.get(Url::from(audience_url.clone()))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| TrustedPublishingError::ReqwestMiddleware(audience_url.clone(), err))?;
|
||||
let audience = response
|
||||
.error_for_status()
|
||||
.map_err(|err| TrustedPublishingError::Reqwest(audience_url.clone(), err))?
|
||||
.json::<Audience>()
|
||||
.await
|
||||
.map_err(|err| TrustedPublishingError::Reqwest(audience_url.clone(), err))?;
|
||||
trace!("The audience is `{}`", &audience.audience);
|
||||
Ok(audience.audience)
|
||||
}
|
||||
|
||||
/// Perform ambient OIDC token discovery.
|
||||
async fn get_oidc_token(
|
||||
audience: &str,
|
||||
@@ -183,65 +164,3 @@ fn decode_oidc_token(oidc_token: &str) -> Option<OidcTokenClaims> {
|
||||
let decoded = BASE64_URL_SAFE_NO_PAD.decode(payload).ok()?;
|
||||
serde_json::from_slice(&decoded).ok()
|
||||
}
|
||||
|
||||
async fn get_publish_token(
|
||||
registry: &DisplaySafeUrl,
|
||||
oidc_token: ambient_id::IdToken,
|
||||
client: &ClientWithMiddleware,
|
||||
) -> Result<TrustedPublishingToken, TrustedPublishingError> {
|
||||
// Prefer HTTPS for OIDC minting; allow HTTP only in test builds
|
||||
let scheme: &str = if cfg!(feature = "test") {
|
||||
registry.scheme()
|
||||
} else {
|
||||
"https"
|
||||
};
|
||||
let mint_token_url = DisplaySafeUrl::parse(&format!(
|
||||
"{}://{}/_/oidc/mint-token",
|
||||
scheme,
|
||||
registry.authority()
|
||||
))?;
|
||||
debug!("Querying the trusted publishing upload token from {mint_token_url}");
|
||||
let mint_token_payload = MintTokenRequest {
|
||||
token: oidc_token.reveal().to_string(),
|
||||
};
|
||||
let response = client
|
||||
.post(Url::from(mint_token_url.clone()))
|
||||
.body(serde_json::to_vec(&mint_token_payload)?)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| TrustedPublishingError::ReqwestMiddleware(mint_token_url.clone(), err))?;
|
||||
|
||||
// reqwest's implementation of `.json()` also goes through `.bytes()`
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|err| TrustedPublishingError::Reqwest(mint_token_url.clone(), err))?;
|
||||
|
||||
if status.is_success() {
|
||||
let publish_token: PublishToken = serde_json::from_slice(&body)?;
|
||||
Ok(publish_token.token)
|
||||
} else {
|
||||
match decode_oidc_token(oidc_token.reveal()) {
|
||||
Some(claims) => {
|
||||
// An error here means that something is misconfigured, e.g. a typo in the PyPI
|
||||
// configuration, so we're showing the body and the JWT claims for more context, see
|
||||
// https://docs.pypi.org/trusted-publishers/troubleshooting/#token-minting
|
||||
// for what the body can mean.
|
||||
Err(TrustedPublishingError::Pypi(
|
||||
status,
|
||||
String::from_utf8_lossy(&body).to_string(),
|
||||
claims,
|
||||
))
|
||||
}
|
||||
None => {
|
||||
// This is not a user configuration error, the OIDC token should always have a valid
|
||||
// format.
|
||||
Err(TrustedPublishingError::InvalidOidcToken(
|
||||
status,
|
||||
String::from_utf8_lossy(&body).to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
127
crates/uv-publish/src/trusted_publishing/pypi.rs
Normal file
127
crates/uv-publish/src/trusted_publishing/pypi.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
//! Services that implement PyPI's Trusted Publishing interfaces.
|
||||
|
||||
use reqwest_middleware::ClientWithMiddleware;
|
||||
use tracing::{debug, trace};
|
||||
use url::Url;
|
||||
use uv_client::BaseClient;
|
||||
use uv_redacted::DisplaySafeUrl;
|
||||
|
||||
use crate::trusted_publishing::{
|
||||
Audience, MintTokenRequest, PublishToken, TrustedPublishingError, TrustedPublishingService,
|
||||
decode_oidc_token,
|
||||
};
|
||||
|
||||
pub(crate) struct PyPIPublishingService<'a> {
|
||||
client: &'a ClientWithMiddleware,
|
||||
registry: &'a DisplaySafeUrl,
|
||||
}
|
||||
|
||||
impl<'a> PyPIPublishingService<'a> {
|
||||
pub(crate) fn new(registry: &'a DisplaySafeUrl, client: &'a BaseClient) -> Self {
|
||||
Self {
|
||||
client: client.for_host(registry).raw_client(),
|
||||
registry,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TrustedPublishingService for PyPIPublishingService<'_> {
|
||||
fn client(&self) -> &ClientWithMiddleware {
|
||||
self.client
|
||||
}
|
||||
|
||||
async fn audience(&self) -> Result<String, super::TrustedPublishingError> {
|
||||
// `pypa/gh-action-pypi-publish` uses `netloc` (RFC 1808), which is deprecated for authority
|
||||
// (RFC 3986).
|
||||
// Prefer HTTPS for OIDC discovery; allow HTTP only in test builds
|
||||
let scheme: &str = if cfg!(feature = "test") {
|
||||
self.registry.scheme()
|
||||
} else {
|
||||
"https"
|
||||
};
|
||||
let audience_url = DisplaySafeUrl::parse(&format!(
|
||||
"{}://{}/_/oidc/audience",
|
||||
scheme,
|
||||
self.registry.authority()
|
||||
))?;
|
||||
debug!("Querying the trusted publishing audience from {audience_url}");
|
||||
let response = self
|
||||
.client
|
||||
.get(Url::from(audience_url.clone()))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| TrustedPublishingError::ReqwestMiddleware(audience_url.clone(), err))?;
|
||||
let audience = response
|
||||
.error_for_status()
|
||||
.map_err(|err| TrustedPublishingError::Reqwest(audience_url.clone(), err))?
|
||||
.json::<Audience>()
|
||||
.await
|
||||
.map_err(|err| TrustedPublishingError::Reqwest(audience_url.clone(), err))?;
|
||||
trace!("The audience is `{}`", &audience.audience);
|
||||
Ok(audience.audience)
|
||||
}
|
||||
|
||||
async fn publish_token(
|
||||
&self,
|
||||
oidc_token: ambient_id::IdToken,
|
||||
) -> Result<super::TrustedPublishingToken, super::TrustedPublishingError> {
|
||||
// Prefer HTTPS for OIDC minting; allow HTTP only in test builds
|
||||
let scheme: &str = if cfg!(feature = "test") {
|
||||
self.registry.scheme()
|
||||
} else {
|
||||
"https"
|
||||
};
|
||||
let mint_token_url = DisplaySafeUrl::parse(&format!(
|
||||
"{}://{}/_/oidc/mint-token",
|
||||
scheme,
|
||||
self.registry.authority()
|
||||
))?;
|
||||
debug!("Querying the trusted publishing upload token from {mint_token_url}");
|
||||
let mint_token_payload = MintTokenRequest {
|
||||
token: oidc_token.reveal().to_string(),
|
||||
};
|
||||
let response = self
|
||||
.client
|
||||
.post(Url::from(mint_token_url.clone()))
|
||||
.body(serde_json::to_vec(&mint_token_payload)?)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
TrustedPublishingError::ReqwestMiddleware(mint_token_url.clone(), err)
|
||||
})?;
|
||||
|
||||
// reqwest's implementation of `.json()` also goes through `.bytes()`
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|err| TrustedPublishingError::Reqwest(mint_token_url.clone(), err))?;
|
||||
|
||||
if status.is_success() {
|
||||
let publish_token: PublishToken = serde_json::from_slice(&body)?;
|
||||
Ok(publish_token.token)
|
||||
} else {
|
||||
match decode_oidc_token(oidc_token.reveal()) {
|
||||
Some(claims) => {
|
||||
// An error here means that something is misconfigured, e.g. a typo in the PyPI
|
||||
// configuration, so we're showing the body and the JWT claims for more context, see
|
||||
// https://docs.pypi.org/trusted-publishers/troubleshooting/#token-minting
|
||||
// for what the body can mean.
|
||||
Err(TrustedPublishingError::Pypi(
|
||||
status,
|
||||
String::from_utf8_lossy(&body).to_string(),
|
||||
claims,
|
||||
))
|
||||
}
|
||||
None => {
|
||||
// This is not a user configuration error, the OIDC token should always have a valid
|
||||
// format.
|
||||
Err(TrustedPublishingError::InvalidOidcToken(
|
||||
status,
|
||||
String::from_utf8_lossy(&body).to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user