Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add oidc support #1181

Merged
merged 6 commits into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(),
))
}
}
Loading