From b4eabf9a61e9edfe2e9e761f7425774e2493b08b Mon Sep 17 00:00:00 2001 From: konsti Date: Thu, 13 Mar 2025 11:19:08 +0100 Subject: [PATCH] Render token claims on publish permission error (#12135) Match the official trusted publishing GitHub Action from https://github.com/pypa/gh-action-pypi-publish/blob/db8f07d3871a0a180efa06b95d467625c19d5d5f/oidc-exchange.py#L165-L184 See https://github.com/konstin/uv/actions/runs/13812003071/job/38635620817?pr=3 for an example of how this renders --- crates/uv-publish/src/trusted_publishing.rs | 62 +++++++++++++++++---- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/crates/uv-publish/src/trusted_publishing.rs b/crates/uv-publish/src/trusted_publishing.rs index 6e47f97e0..23d516456 100644 --- a/crates/uv-publish/src/trusted_publishing.rs +++ b/crates/uv-publish/src/trusted_publishing.rs @@ -1,5 +1,7 @@ //! Trusted publishing (via OIDC) with GitHub actions. +use base64::prelude::BASE64_URL_SAFE_NO_PAD; +use base64::Engine; use reqwest::{header, StatusCode}; use reqwest_middleware::ClientWithMiddleware; use serde::{Deserialize, Serialize}; @@ -27,9 +29,12 @@ pub enum TrustedPublishingError { #[error(transparent)] SerdeJson(#[from] serde_json::error::Error), #[error( - "PyPI returned error code {0}, is trusted publishing correctly configured?\nResponse: {1}" + "PyPI returned error code {0}, is trusted publishing correctly configured?\nResponse: {1}\nToken claims, which must match the PyPI configuration: {2:#?}" )] - Pypi(StatusCode, String), + Pypi(StatusCode, String, OidcTokenClaims), + /// When trusted publishing is misconfigured, the error above should occur, not this one. + #[error("PyPI returned error code {0}, and the OIDC has an unexpected format.\nResponse: {1}")] + InvalidOidcToken(StatusCode, String), } impl TrustedPublishingError { @@ -75,6 +80,18 @@ struct PublishToken { token: TrustedPublishingToken, } +/// The payload of the OIDC token. +#[derive(Deserialize, Debug)] +#[allow(dead_code)] +pub struct OidcTokenClaims { + sub: String, + repository: String, + repository_owner: String, + repository_owner_id: String, + job_workflow_ref: String, + r#ref: String, +} + /// Returns the short-lived token to use for uploading. pub(crate) async fn get_token( registry: &Url, @@ -158,6 +175,18 @@ async fn get_oidc_token( Ok(oidc_token.value) } +/// Parse the JSON Web Token that the OIDC token is. +/// +/// See: +fn decode_oidc_token(oidc_token: &str) -> Option { + let token_segments = oidc_token.splitn(3, '.').collect::>(); + let [_header, payload, _signature] = *token_segments.into_boxed_slice() else { + return None; + }; + let decoded = BASE64_URL_SAFE_NO_PAD.decode(payload).ok()?; + serde_json::from_slice(&decoded).ok() +} + async fn get_publish_token( registry: &Url, oidc_token: &str, @@ -189,13 +218,26 @@ async fn get_publish_token( let publish_token: PublishToken = serde_json::from_slice(&body)?; Ok(publish_token.token) } else { - // An error here means that something is misconfigured, e.g. a typo in the PyPI - // configuration, so we're showing the body 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(), - )) + match decode_oidc_token(oidc_token) { + 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(), + )) + } + } } }