Abstract Trusted Publishing services (#17418)

This commit is contained in:
William Woodruff
2026-01-13 10:53:58 -05:00
committed by GitHub
parent c43fb6103b
commit 6dfa0e3722
3 changed files with 155 additions and 109 deletions

View File

@@ -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(),

View File

@@ -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(),
))
}
}
}
}

View 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(),
))
}
}
}
}
}