Skip to content

Commit

Permalink
feat: add oidc trusted publisher support for uploads to prefix (#1181)
Browse files Browse the repository at this point in the history
Co-authored-by: Wolf Vollprecht <w.vollprecht@gmail.com>
  • Loading branch information
Hofer-Julian and wolfv authored Nov 13, 2024
1 parent bfa9f71 commit 108bf84
Show file tree
Hide file tree
Showing 4 changed files with 231 additions and 16 deletions.
13 changes: 10 additions & 3 deletions src/console_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ use tracing_subscriber::{
EnvFilter, Layer,
};

use crate::consts;

/// A custom formatter for tracing events.
pub struct TracingFormatter;

Expand Down Expand Up @@ -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())
}
12 changes: 12 additions & 0 deletions src/consts.rs
Original file line number Diff line number Diff line change
@@ -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";
39 changes: 26 additions & 13 deletions src/upload/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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");

Expand Down Expand Up @@ -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()?
}
},
};
Expand All @@ -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
Expand Down
183 changes: 183 additions & 0 deletions src/upload/trusted_publishing.rs
Original file line number Diff line number Diff line change
@@ -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<TrustedPublishingToken, TrustedPublishingError> {
// 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<String, TrustedPublishingError> {
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<TrustedPublishingToken, TrustedPublishingError> {
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(),
))
}
}

0 comments on commit 108bf84

Please sign in to comment.