-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
13 changed files
with
434 additions
and
44 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,153 @@ | ||
//! Trusted publishing (via OIDC) with GitHub actions. | ||
use reqwest::{header, Client, StatusCode}; | ||
use serde::{Deserialize, Serialize}; | ||
use std::env; | ||
use std::env::VarError; | ||
use thiserror::Error; | ||
use tracing::{debug, trace}; | ||
use url::Url; | ||
use uv_client::BaseClient; | ||
|
||
#[derive(Debug, Error)] | ||
pub enum TrustedPublishingError { | ||
#[error(transparent)] | ||
Var(#[from] VarError), | ||
#[error(transparent)] | ||
Url(#[from] url::ParseError), | ||
#[error(transparent)] | ||
Reqwest(#[from] reqwest::Error), | ||
#[error(transparent)] | ||
SerdeJson(#[from] serde_json::error::Error), | ||
#[error( | ||
"PyPI returned error code {0}, is trusted publishing correctly configured?\nResponse: {1}" | ||
)] | ||
Pypi(StatusCode, String), | ||
} | ||
|
||
/// The response from querying `https://pypi.org/_/oidc/audience`. | ||
#[derive(Deserialize)] | ||
struct Audience { | ||
audience: String, | ||
} | ||
|
||
/// The response from querying `$ACTIONS_ID_TOKEN_REQUEST_URL&audience=pypi`. | ||
#[derive(Deserialize)] | ||
struct OidcToken { | ||
value: String, | ||
} | ||
|
||
/// The body for querying `$ACTIONS_ID_TOKEN_REQUEST_URL&audience=pypi`. | ||
#[derive(Serialize)] | ||
struct MintTokenRequest { | ||
token: String, | ||
} | ||
|
||
/// The response from querying `$ACTIONS_ID_TOKEN_REQUEST_URL&audience=pypi`. | ||
#[derive(Deserialize)] | ||
struct PublishToken { | ||
token: String, | ||
} | ||
|
||
/// Returns the short-lived token to use for uploading. | ||
pub(crate) async fn get_token( | ||
registry: &Url, | ||
client: &BaseClient, | ||
) -> Result<String, TrustedPublishingError> { | ||
// If this fails, we can skip the audience request. | ||
let oidc_token_request_token = env::var("ACTIONS_ID_TOKEN_REQUEST_TOKEN")?; | ||
|
||
// When communicating with GitHub and PyPI, we don't want any custom ssl settings or retries, so | ||
// we use a default client. | ||
let client = client.raw_client(); | ||
|
||
// Request 1: Get the audience | ||
let audience = get_audience(registry, &client).await?; | ||
|
||
// Request 2: Get the OIDC token from GitHub. | ||
let oidc_token = get_oidc_token(&audience, &oidc_token_request_token, &client).await?; | ||
|
||
// Request 3: Get the publishing token from PyPI. | ||
let publish_token = get_publish_token(registry, &oidc_token, client).await?; | ||
|
||
debug!("Received token, using trusted publishing"); | ||
|
||
// Tell GitHub Actions to mask the token in any console logs. | ||
#[allow(clippy::print_stdout)] | ||
{ | ||
// If we successfully obtained a token, we know we must be in GitHub Actions, so it's safe | ||
// to use GitHub Actions commands. | ||
println!("::add-mask::{}", &publish_token); | ||
} | ||
|
||
Ok(publish_token) | ||
} | ||
|
||
async fn get_audience(registry: &Url, client: &Client) -> Result<String, TrustedPublishingError> { | ||
// `pypa/gh-action-pypi-publish` uses `netloc` (RFC 1808), which is deprecated for authority | ||
// (RFC 3986). | ||
let audience_url = Url::parse(&format!("https://{}/_/oidc/audience", registry.authority()))?; | ||
debug!("Querying the trusted publishing audience from {audience_url}"); | ||
let response = client.get(audience_url).send().await?; | ||
let audience = response.error_for_status()?.json::<Audience>().await?; | ||
trace!("The audience is `{}`", &audience.audience); | ||
Ok(audience.audience) | ||
} | ||
|
||
async fn get_oidc_token( | ||
audience: &str, | ||
oidc_token_request_token: &str, | ||
client: &Client, | ||
) -> Result<String, TrustedPublishingError> { | ||
let mut oidc_token_url = Url::parse(&env::var("ACTIONS_ID_TOKEN_REQUEST_URL")?)?; | ||
oidc_token_url | ||
.query_pairs_mut() | ||
.append_pair("audience", audience); | ||
debug!("Querying the trusted publishing OIDC token from {oidc_token_url}"); | ||
let authorization = format!("bearer {oidc_token_request_token}"); | ||
let response = client | ||
.get(oidc_token_url) | ||
.header(header::AUTHORIZATION, authorization) | ||
.send() | ||
.await?; | ||
let oidc_token: OidcToken = response.error_for_status()?.json().await?; | ||
Ok(oidc_token.value) | ||
} | ||
|
||
async fn get_publish_token( | ||
registry: &Url, | ||
oidc_token: &str, | ||
client: Client, | ||
) -> Result<String, TrustedPublishingError> { | ||
let mint_token_url = Url::parse(&format!( | ||
"https://{}/_/oidc/mint-token", | ||
registry.authority() | ||
))?; | ||
debug!("Querying the trusted publishing upload token from {mint_token_url}"); | ||
let mint_token_payload = MintTokenRequest { | ||
token: oidc_token.to_string(), | ||
}; | ||
let response = client | ||
.post(mint_token_url) | ||
.body(serde_json::to_vec(&mint_token_payload)?) | ||
.send() | ||
.await?; | ||
|
||
// reqwest's implementation of `.json()` also goes through `.bytes()` | ||
let status = response.status(); | ||
let body = response.bytes().await?; | ||
|
||
if status.is_success() { | ||
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(), | ||
)) | ||
} | ||
} |
Oops, something went wrong.