diff --git a/src/console_utils.rs b/src/console_utils.rs index 9b88a5d3..67518352 100644 --- a/src/console_utils.rs +++ b/src/console_utils.rs @@ -28,6 +28,8 @@ use tracing_subscriber::{ EnvFilter, Layer, }; +use crate::consts; + /// A custom formatter for tracing events. pub struct TracingFormatter; @@ -648,8 +650,13 @@ pub fn init_logging( Ok(log_handler) } -/// check if we are on Github CI and if the user has enabled the integration +/// Checks whether we are on GitHub Actions and if the user has enabled the GitHub integration pub fn github_integration_enabled() -> bool { - std::env::var("GITHUB_ACTIONS").is_ok() - && std::env::var("RATTLER_BUILD_ENABLE_GITHUB_INTEGRATION") == Ok("true".to_string()) + github_action_runner() + && std::env::var(consts::RATTLER_BUILD_ENABLE_GITHUB_INTEGRATION) == Ok("true".to_string()) +} + +/// Checks whether we are on GitHub Actions +pub fn github_action_runner() -> bool { + std::env::var(consts::GITHUB_ACTIONS) == Ok("true".to_string()) } diff --git a/src/consts.rs b/src/consts.rs index 493649f8..4e74604d 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -1,3 +1,15 @@ /// A `recipe.yaml` file might be accompanied by a `variants.toml` file from /// which we can read variant configuration for that specific recipe.. pub const VARIANTS_CONFIG_FILE: &str = "variants.yaml"; + +/// This env var is set to "true" when run inside a github actions runner +pub const GITHUB_ACTIONS: &str = "GITHUB_ACTIONS"; + +/// This env var contains the oidc token url +pub const ACTIONS_ID_TOKEN_REQUEST_URL: &str = "ACTIONS_ID_TOKEN_REQUEST_URL"; + +/// This env var contains the oidc request token +pub const ACTIONS_ID_TOKEN_REQUEST_TOKEN: &str = "ACTIONS_ID_TOKEN_REQUEST_TOKEN"; + +// This env var determines whether GitHub integration is enabled +pub const RATTLER_BUILD_ENABLE_GITHUB_INTEGRATION: &str = "RATTLER_BUILD_ENABLE_GITHUB_INTEGRATION"; diff --git a/src/upload/mod.rs b/src/upload/mod.rs index fa79903d..f75a1b84 100644 --- a/src/upload/mod.rs +++ b/src/upload/mod.rs @@ -8,6 +8,7 @@ use std::{ path::{Path, PathBuf}, }; use tokio_util::io::ReaderStream; +use trusted_publishing::{check_trusted_publishing, TrustedPublishResult}; use miette::{Context, IntoDiagnostic}; use rattler_networking::{Authentication, AuthenticationStorage}; @@ -21,6 +22,7 @@ use crate::upload::package::{sha256_sum, ExtractedPackage}; mod anaconda; pub mod conda_forge; mod package; +mod trusted_publishing; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -179,23 +181,36 @@ pub async fn upload_package_to_prefix( url: Url, channel: String, ) -> miette::Result<()> { - let token = match api_key { - Some(api_key) => api_key, - None => match storage.get_by_url(url.clone()) { - Ok((_, Some(Authentication::BearerToken(token)))) => token, + let check_storage = || { + match storage.get_by_url(url.clone()) { + Ok((_, Some(Authentication::BearerToken(token)))) => Ok(token), Ok((_, Some(_))) => { - return Err(miette::miette!("A Conda token is required for authentication with prefix.dev. - Authentication information found in the keychain / auth file, but it was not a Bearer token")); + Err(miette::miette!("A Conda token is required for authentication with prefix.dev. + Authentication information found in the keychain / auth file, but it was not a Bearer token")) } Ok((_, None)) => { - return Err(miette::miette!( + Err(miette::miette!( "No prefix.dev api key was given and none was found in the keychain / auth file" - )); + )) } Err(e) => { - return Err(miette::miette!( - "Failed to get authentication information form keychain: {e}" - )); + Err(miette::miette!( + "Failed to get authentication information from keychain: {e}" + )) + } + } + }; + + let client = get_default_client().into_diagnostic()?; + + let token = match api_key { + Some(api_key) => api_key, + None => match check_trusted_publishing(&client, &url).await { + TrustedPublishResult::Configured(token) => token.secret().to_string(), + TrustedPublishResult::Skipped => check_storage()?, + TrustedPublishResult::Ignored(err) => { + tracing::warn!("Checked for trusted publishing but failed with {err}"); + check_storage()? } }, }; @@ -213,8 +228,6 @@ pub async fn upload_package_to_prefix( .join(&format!("api/v1/upload/{}", channel)) .into_diagnostic()?; - let client = get_default_client().into_diagnostic()?; - let hash = sha256_sum(package_file).into_diagnostic()?; let prepared_request = client diff --git a/src/upload/trusted_publishing.rs b/src/upload/trusted_publishing.rs new file mode 100644 index 00000000..010845b4 --- /dev/null +++ b/src/upload/trusted_publishing.rs @@ -0,0 +1,183 @@ +// This code has been adapted from uv under https://github.com/astral-sh/uv/blob/c5caf92edf539a9ebf24d375871178f8f8a0ab93/crates/uv-publish/src/trusted_publishing.rs +// The original code is dual-licensed under Apache-2.0 and MIT + +//! Trusted publishing (via OIDC) with GitHub actions. + +use reqwest::{header, Client, StatusCode}; +use serde::{Deserialize, Serialize}; +use std::env; +use std::env::VarError; +use std::ffi::OsString; +use thiserror::Error; +use url::Url; + +use crate::{console_utils::github_action_runner, consts}; + +/// If applicable, attempt obtaining a token for trusted publishing. +pub async fn check_trusted_publishing(client: &Client, prefix_url: &Url) -> TrustedPublishResult { + // If we aren't in GitHub Actions, we can't use trusted publishing. + if !github_action_runner() { + return TrustedPublishResult::Skipped; + } + // We could check for credentials from the keyring or netrc the auth middleware first, but + // given that we are in GitHub Actions we check for trusted publishing first. + tracing::debug!( + "Running on GitHub Actions without explicit credentials, checking for trusted publishing" + ); + match get_token(client, prefix_url).await { + Ok(token) => TrustedPublishResult::Configured(token), + Err(err) => { + tracing::debug!("Could not obtain trusted publishing credentials, skipping: {err}"); + TrustedPublishResult::Ignored(err) + } + } +} + +pub enum TrustedPublishResult { + /// We didn't check for trusted publishing. + Skipped, + /// We checked for trusted publishing and found a token. + Configured(TrustedPublishingToken), + /// We checked for optional trusted publishing, but it didn't succeed. + Ignored(TrustedPublishingError), +} + +#[derive(Debug, Error)] +pub enum TrustedPublishingError { + #[error("Environment variable {0} not set, is the `id-token: write` permission missing?")] + MissingEnvVar(&'static str), + #[error("Environment variable {0} is not valid UTF-8: `{1:?}`")] + InvalidEnvVar(&'static str, OsString), + #[error(transparent)] + Url(#[from] url::ParseError), + #[error("Failed to fetch: `{0}`")] + Reqwest(Url, #[source] reqwest::Error), + #[error( + "Prefix.dev returned error code {0}, is trusted publishing correctly configured?\nResponse: {1}" + )] + PrefixDev(StatusCode, String), +} + +impl TrustedPublishingError { + fn from_var_err(env_var: &'static str, err: VarError) -> Self { + match err { + VarError::NotPresent => Self::MissingEnvVar(env_var), + VarError::NotUnicode(os_string) => Self::InvalidEnvVar(env_var, os_string), + } + } +} + +#[derive(Deserialize)] +#[serde(transparent)] +pub struct TrustedPublishingToken(String); + +impl TrustedPublishingToken { + pub fn secret(&self) -> &str { + &self.0 + } +} + +/// The response from querying `$ACTIONS_ID_TOKEN_REQUEST_URL&audience=prefix.dev`. +#[derive(Deserialize)] +struct OidcToken { + value: String, +} + +/// The body for querying `$ACTIONS_ID_TOKEN_REQUEST_URL&audience=prefix.dev`. +#[derive(Serialize)] +struct MintTokenRequest { + token: String, +} + +/// Returns the short-lived token to use for uploading. +pub(crate) async fn get_token( + client: &Client, + prefix_url: &Url, +) -> Result { + // If this fails, we can skip the audience request. + let oidc_token_request_token = + env::var(consts::ACTIONS_ID_TOKEN_REQUEST_TOKEN).map_err(|err| { + TrustedPublishingError::from_var_err(consts::ACTIONS_ID_TOKEN_REQUEST_TOKEN, err) + })?; + + // Request 1: Get the OIDC token from GitHub. + let oidc_token = get_oidc_token(&oidc_token_request_token, client).await?; + + // Request 2: Get the publishing token from prefix.dev. + let publish_token = get_publish_token(&oidc_token, prefix_url, client).await?; + + tracing::info!("Received token, using trusted publishing"); + + // Tell GitHub Actions to mask the token in any console logs. + if github_action_runner() { + println!("::add-mask::{}", &publish_token.secret()); + } + + Ok(publish_token) +} + +async fn get_oidc_token( + oidc_token_request_token: &str, + client: &Client, +) -> Result { + let oidc_token_url = env::var(consts::ACTIONS_ID_TOKEN_REQUEST_URL).map_err(|err| { + TrustedPublishingError::from_var_err(consts::ACTIONS_ID_TOKEN_REQUEST_URL, err) + })?; + let mut oidc_token_url = Url::parse(&oidc_token_url)?; + oidc_token_url + .query_pairs_mut() + .append_pair("audience", "prefix.dev"); + tracing::info!("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.clone()) + .header(header::AUTHORIZATION, authorization) + .send() + .await + .map_err(|err| TrustedPublishingError::Reqwest(oidc_token_url.clone(), err))?; + let oidc_token: OidcToken = response + .error_for_status() + .map_err(|err| TrustedPublishingError::Reqwest(oidc_token_url.clone(), err))? + .json() + .await + .map_err(|err| TrustedPublishingError::Reqwest(oidc_token_url.clone(), err))?; + Ok(oidc_token.value) +} + +async fn get_publish_token( + oidc_token: &str, + prefix_url: &Url, + client: &Client, +) -> Result { + let mint_token_url = prefix_url.join("/api/oidc/mint_token")?; + tracing::info!("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.clone()) + .json(&mint_token_payload) + .send() + .await + .map_err(|err| TrustedPublishingError::Reqwest(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 token = TrustedPublishingToken(String::from_utf8_lossy(&body).to_string()); + Ok(token) + } else { + // An error here means that something is misconfigured, + // so we're showing the body for more context + Err(TrustedPublishingError::PrefixDev( + status, + String::from_utf8_lossy(&body).to_string(), + )) + } +}