From 6dfa0e37225387d3c424de5a6adefa0c5d0b4036 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Tue, 13 Jan 2026 10:53:58 -0500 Subject: [PATCH] Abstract Trusted Publishing services (#17418) --- crates/uv-publish/src/lib.rs | 14 +- crates/uv-publish/src/trusted_publishing.rs | 123 +++-------------- .../uv-publish/src/trusted_publishing/pypi.rs | 127 ++++++++++++++++++ 3 files changed, 155 insertions(+), 109 deletions(-) create mode 100644 crates/uv-publish/src/trusted_publishing/pypi.rs diff --git a/crates/uv-publish/src/lib.rs b/crates/uv-publish/src/lib.rs index 1c29e9715..6d21d3cca 100644 --- a/crates/uv-publish/src/lib.rs +++ b/crates/uv-publish/src/lib.rs @@ -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(), diff --git a/crates/uv-publish/src/trusted_publishing.rs b/crates/uv-publish/src/trusted_publishing.rs index 7632fe30b..b93271125 100644 --- a/crates/uv-publish/src/trusted_publishing.rs +++ b/crates/uv-publish/src/trusted_publishing.rs @@ -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; + + /// 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; +} + /// 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, 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 { - // `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::() - .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 { 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 { - // 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(), - )) - } - } - } -} diff --git a/crates/uv-publish/src/trusted_publishing/pypi.rs b/crates/uv-publish/src/trusted_publishing/pypi.rs new file mode 100644 index 000000000..8651b1f95 --- /dev/null +++ b/crates/uv-publish/src/trusted_publishing/pypi.rs @@ -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 { + // `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::() + .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 { + // 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(), + )) + } + } + } + } +}