From afe3221354889c669e92a378036e062a3623d2af Mon Sep 17 00:00:00 2001 From: fargito Date: Wed, 12 Nov 2025 18:14:21 +0100 Subject: [PATCH] feat(provider): add support for oidc token authentication --- src/request_client.rs | 18 +- src/run/mod.rs | 6 + src/run/run_environment/buildkite/provider.rs | 13 ++ .../github_actions/provider.rs | 158 +++++++++++++++++- .../github_actions/samples/fork-pr-event.json | 3 + .../github_actions/samples/pr-event.json | 3 + .../github_actions/samples/push-event.json | 5 + src/run/run_environment/gitlab_ci/provider.rs | 14 ++ src/run/run_environment/local/provider.rs | 2 + src/run/run_environment/provider.rs | 26 +++ 10 files changed, 239 insertions(+), 9 deletions(-) create mode 100644 src/run/run_environment/github_actions/samples/push-event.json diff --git a/src/request_client.rs b/src/request_client.rs index 20af51e9..42de30ee 100644 --- a/src/request_client.rs +++ b/src/request_client.rs @@ -4,11 +4,13 @@ use reqwest_middleware::{ClientBuilder as ClientWithMiddlewareBuilder, ClientWit use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff}; const UPLOAD_RETRY_COUNT: u32 = 3; +const OIDC_RETRY_COUNT: u32 = 10; +const USER_AGENT: &str = "codspeed-runner"; lazy_static! { pub static ref REQUEST_CLIENT: ClientWithMiddleware = ClientWithMiddlewareBuilder::new( ClientBuilder::new() - .user_agent("codspeed-runner") + .user_agent(USER_AGENT) .build() .unwrap() ) @@ -19,7 +21,19 @@ lazy_static! { // Client without retry middleware for streaming uploads (can't be cloned) pub static ref STREAMING_CLIENT: reqwest::Client = ClientBuilder::new() - .user_agent("codspeed-runner") + .user_agent(USER_AGENT) .build() .unwrap(); + + // Client with retry middleware for OIDC token requests + pub static ref OIDC_CLIENT: ClientWithMiddleware = ClientWithMiddlewareBuilder::new( + ClientBuilder::new() + .user_agent(USER_AGENT) + .build() + .unwrap() + ) + .with(RetryTransientMiddleware::new_with_policy( + ExponentialBackoff::builder().build_with_max_retries(OIDC_RETRY_COUNT) + )) + .build(); } diff --git a/src/run/mod.rs b/src/run/mod.rs index 5ac4f66f..1704ec5c 100644 --- a/src/run/mod.rs +++ b/src/run/mod.rs @@ -68,6 +68,9 @@ pub struct RunArgs { pub upload_url: Option, /// The token to use for uploading the results, + /// + /// It can be either a CodSpeed token retrieved from the repository setting + /// or an OIDC token issued by the identity provider. #[arg(long, env = "CODSPEED_TOKEN")] pub token: Option, @@ -205,6 +208,9 @@ pub async fn run( } debug!("Using the token from the CodSpeed configuration file"); config.set_token(codspeed_config.auth.token.clone()); + } else { + // If relevant, set the OIDC token for authentication + provider.set_oidc_token(&mut config).await?; } let system_info = SystemInfo::new()?; diff --git a/src/run/run_environment/buildkite/provider.rs b/src/run/run_environment/buildkite/provider.rs index b4105993..c144f5b9 100644 --- a/src/run/run_environment/buildkite/provider.rs +++ b/src/run/run_environment/buildkite/provider.rs @@ -1,5 +1,6 @@ use std::env; +use async_trait::async_trait; use simplelog::SharedLogger; use crate::prelude::*; @@ -119,6 +120,7 @@ impl RunEnvironmentDetector for BuildkiteProvider { } } +#[async_trait(?Send)] impl RunEnvironmentProvider for BuildkiteProvider { fn get_repository_provider(&self) -> RepositoryProvider { RepositoryProvider::GitHub @@ -151,6 +153,17 @@ impl RunEnvironmentProvider for BuildkiteProvider { fn get_run_provider_run_part(&self) -> Option { None } + + /// For now, we do not support OIDC tokens for Buildkite + /// + /// If we want to in the future, we can implement it using the Buildkite Agent CLI. + /// + /// Docs: + /// - https://buildkite.com/docs/agent/v3/cli-oidc + /// - https://buildkite.com/docs/pipelines/security/oidc + async fn set_oidc_token(&self, _config: &mut Config) -> Result<()> { + Ok(()) + } } #[cfg(test)] diff --git a/src/run/run_environment/github_actions/provider.rs b/src/run/run_environment/github_actions/provider.rs index baf2ade9..cf228473 100644 --- a/src/run/run_environment/github_actions/provider.rs +++ b/src/run/run_environment/github_actions/provider.rs @@ -1,12 +1,15 @@ +use async_trait::async_trait; use git2::Repository; use lazy_static::lazy_static; use regex::Regex; +use serde::Deserialize; use serde_json::Value; use simplelog::SharedLogger; use std::collections::BTreeMap; use std::{env, fs}; use crate::prelude::*; +use crate::request_client::OIDC_CLIENT; use crate::run::run_environment::{RunEnvironment, RunPart}; use crate::run::{ config::Config, @@ -30,6 +33,12 @@ pub struct GitHubActionsProvider { pub gh_data: GhData, pub event: RunEvent, pub repository_root_path: String, + + /// Indicates whether the head repository is a fork of the base repository. + is_head_repo_fork: bool, + + /// Indicates whether the repository is private. + is_repository_private: bool, } impl GitHubActionsProvider { @@ -42,6 +51,11 @@ impl GitHubActionsProvider { } } +#[derive(Deserialize)] +struct OIDCResponse { + value: Option, +} + lazy_static! { static ref PR_REF_REGEX: Regex = Regex::new(r"^refs/pull/(?P\d+)/merge$").unwrap(); } @@ -53,13 +67,18 @@ impl TryFrom<&Config> for GitHubActionsProvider { bail!("Specifying owner and repository from CLI is not supported for Github Actions"); } let (owner, repository) = Self::get_owner_and_repository()?; + + let github_event_path = get_env_variable("GITHUB_EVENT_PATH")?; + let github_event = fs::read_to_string(github_event_path)?; + let github_event: Value = + serde_json::from_str(&github_event).expect("GITHUB_EVENT_PATH file could not be read"); + let ref_ = get_env_variable("GITHUB_REF")?; let is_pr = PR_REF_REGEX.is_match(&ref_); - let head_ref = if is_pr { - let github_event_path = get_env_variable("GITHUB_EVENT_PATH")?; - let github_event = fs::read_to_string(github_event_path)?; - let github_event: Value = serde_json::from_str(&github_event) - .expect("GITHUB_EVENT_PATH file could not be read"); + + let is_repository_private = github_event["repository"]["private"].as_bool().unwrap(); + + let (head_ref, is_head_repo_fork) = if is_pr { let pull_request = github_event["pull_request"].as_object().unwrap(); let head_repo = pull_request["head"]["repo"].as_object().unwrap(); @@ -76,9 +95,9 @@ impl TryFrom<&Config> for GitHubActionsProvider { } else { pull_request["head"]["ref"].as_str().unwrap().to_owned() }; - Some(head_ref) + (Some(head_ref), is_head_repo_fork) } else { - None + (None, false) }; let github_event_name = get_env_variable("GITHUB_EVENT_NAME")?; @@ -118,6 +137,8 @@ impl TryFrom<&Config> for GitHubActionsProvider { }), base_ref: get_env_variable("GITHUB_BASE_REF").ok(), repository_root_path, + is_head_repo_fork, + is_repository_private, }) } } @@ -129,6 +150,7 @@ impl RunEnvironmentDetector for GitHubActionsProvider { } } +#[async_trait(?Send)] impl RunEnvironmentProvider for GitHubActionsProvider { fn get_repository_provider(&self) -> RepositoryProvider { RepositoryProvider::GitHub @@ -236,6 +258,99 @@ impl RunEnvironmentProvider for GitHubActionsProvider { .to_string(); Ok(commit_hash) } + + /// Set the OIDC token for GitHub Actions if necessary + /// + /// ## Logic + /// - If the user has explicitly set a token in the configuration (i.e. "static token"), do not override it, but display an info message. + /// - Otherwise, check if the necessary environment variables are set to use OIDC. + /// - Then attempt to request an OIDC token. + /// + /// If environment variables are not set, this could be because: + /// - The user has misconfigured the workflow (missing `id-token` permission) + /// - The run is from a public fork, in which case GitHub Actions does not provide these environment variables for security reasons. + /// + /// + /// ## Notes + /// Retrieving the token requires that the workflow has the `id-token` permission enabled. + /// + /// Docs: + /// - https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-with-reusable-workflows + /// - https://docs.github.com/en/actions/concepts/security/openid-connect + /// - https://docs.github.com/en/actions/reference/security/oidc#methods-for-requesting-the-oidc-token + async fn set_oidc_token(&self, config: &mut Config) -> Result<()> { + // Check if a static token is already set + if config.token.is_some() { + info!( + "CodSpeed now supports OIDC tokens for authentication.\n\ + Benefit from enhanced security by adding the `id-token: write` permission to your workflow and removing the static token from your configuration.\n\ + Learn more at https://codspeed.io/docs/integrations/ci/github-actions#openid-connect-oidc-authentication" + ); + + return Ok(()); + } + + // The `ACTIONS_ID_TOKEN_REQUEST_TOKEN` environment variable is set when the `id-token` permission is granted, which is necessary to authenticate with OIDC. + let request_token = get_env_variable("ACTIONS_ID_TOKEN_REQUEST_TOKEN").ok(); + let request_url = get_env_variable("ACTIONS_ID_TOKEN_REQUEST_URL").ok(); + + if request_token.is_none() || request_url.is_none() { + // If the run is from a fork, it is expected that these environment variables are not set. + // We will fall back to tokenless authentication in this case. + if self.is_head_repo_fork { + return Ok(()); + } + + if self.is_repository_private { + bail!( + "Unable to retrieve OIDC token for authentication. \n\ + Make sure your workflow has the `id-token: write` permission set. \n\ + See https://codspeed.io/docs/integrations/ci/github-actions#openid-connect-oidc-authentication" + ) + } + + info!( + "CodSpeed now supports OIDC tokens for authentication.\n\ + Benefit from enhanced security and faster processing times by adding the `id-token: write` permission to your workflow.\n\ + Learn more at https://codspeed.io/docs/integrations/ci/github-actions#openid-connect-oidc-authentication" + ); + + return Ok(()); + } + + let request_url = request_url.unwrap(); + let request_url = format!("{request_url}&audience={}", self.get_oidc_audience()); + let request_token = request_token.unwrap(); + + let token = match OIDC_CLIENT + .get(request_url) + .header("Accept", "application/json") + .header("Authorization", format!("Bearer {request_token}")) + .send() + .await + { + Ok(response) => match response.json::().await { + Ok(oidc_response) => oidc_response.value, + Err(_) => None, + }, + Err(_) => None, + }; + + if token.is_some() { + debug!("Successfully retrieved OIDC token for authentication."); + config.set_token(token); + } else if self.is_repository_private { + bail!( + "Unable to retrieve OIDC token for authentication. \n\ + Make sure your workflow has the `id-token: write` permission set. \n\ + See https://codspeed.io/docs/integrations/ci/github-actions#openid-connect-oidc-authentication" + ) + } else { + warn!("Failed to retrieve OIDC token for authentication."); + } + + Ok(()) + } } #[cfg(test)] @@ -271,6 +386,16 @@ mod tests { ("GITHUB_ACTOR", Some("actor")), ("GITHUB_BASE_REF", Some("main")), ("GITHUB_EVENT_NAME", Some("push")), + ( + "GITHUB_EVENT_PATH", + Some( + format!( + "{}/src/run/run_environment/github_actions/samples/push-event.json", + env!("CARGO_MANIFEST_DIR") + ) + .as_str(), + ), + ), ("GITHUB_JOB", Some("job")), ("GITHUB_REF", Some("refs/heads/main")), ("GITHUB_REPOSITORY", Some("owner/repository")), @@ -298,6 +423,8 @@ mod tests { github_actions_provider.sender.as_ref().unwrap().id, "1234567890" ); + assert!(!github_actions_provider.is_head_repo_fork); + assert!(!github_actions_provider.is_repository_private); }, ) } @@ -338,6 +465,9 @@ mod tests { ..Config::test() }; let github_actions_provider = GitHubActionsProvider::try_from(&config).unwrap(); + assert!(!github_actions_provider.is_head_repo_fork); + assert!(github_actions_provider.is_repository_private); + let run_environment_metadata = github_actions_provider .get_run_environment_metadata() .unwrap(); @@ -391,6 +521,9 @@ mod tests { ..Config::test() }; let github_actions_provider = GitHubActionsProvider::try_from(&config).unwrap(); + assert!(github_actions_provider.is_head_repo_fork); + assert!(!github_actions_provider.is_repository_private); + let run_environment_metadata = github_actions_provider .get_run_environment_metadata() .unwrap(); @@ -471,6 +604,9 @@ mod tests { ..Config::test() }; let github_actions_provider = GitHubActionsProvider::try_from(&config).unwrap(); + assert!(!github_actions_provider.is_head_repo_fork); + assert!(github_actions_provider.is_repository_private); + let run_environment_metadata = github_actions_provider .get_run_environment_metadata() .unwrap(); @@ -503,6 +639,8 @@ mod tests { }, event: RunEvent::Push, repository_root_path: "/home/work/my-repo".into(), + is_head_repo_fork: false, + is_repository_private: false, }; let run_part = github_actions_provider.get_run_provider_run_part().unwrap(); @@ -545,6 +683,8 @@ mod tests { }, event: RunEvent::Push, repository_root_path: "/home/work/my-repo".into(), + is_head_repo_fork: false, + is_repository_private: false, }; let run_part = github_actions_provider.get_run_provider_run_part().unwrap(); @@ -596,6 +736,8 @@ mod tests { }, event: RunEvent::Push, repository_root_path: "/home/work/my-repo".into(), + is_head_repo_fork: false, + is_repository_private: false, }; let run_part = github_actions_provider.get_run_provider_run_part().unwrap(); @@ -645,6 +787,8 @@ mod tests { }, event: RunEvent::Push, repository_root_path: "/home/work/my-repo".into(), + is_head_repo_fork: false, + is_repository_private: false, }; let run_part = github_actions_provider.get_run_provider_run_part().unwrap(); diff --git a/src/run/run_environment/github_actions/samples/fork-pr-event.json b/src/run/run_environment/github_actions/samples/fork-pr-event.json index 7e4c83e1..e632a047 100644 --- a/src/run/run_environment/github_actions/samples/fork-pr-event.json +++ b/src/run/run_environment/github_actions/samples/fork-pr-event.json @@ -15,5 +15,8 @@ }, "sha": "24809d9fca9ad0808a777bcbd807ecd5ec8a9100" } + }, + "repository": { + "private": false } } diff --git a/src/run/run_environment/github_actions/samples/pr-event.json b/src/run/run_environment/github_actions/samples/pr-event.json index 1b8c4935..77760743 100644 --- a/src/run/run_environment/github_actions/samples/pr-event.json +++ b/src/run/run_environment/github_actions/samples/pr-event.json @@ -12,5 +12,8 @@ }, "sha": "24809d9fca9ad0808a777bcbd807ecd5ec8a9100" } + }, + "repository": { + "private": true } } diff --git a/src/run/run_environment/github_actions/samples/push-event.json b/src/run/run_environment/github_actions/samples/push-event.json new file mode 100644 index 00000000..54c06eb3 --- /dev/null +++ b/src/run/run_environment/github_actions/samples/push-event.json @@ -0,0 +1,5 @@ +{ + "repository": { + "private": false + } +} diff --git a/src/run/run_environment/gitlab_ci/provider.rs b/src/run/run_environment/gitlab_ci/provider.rs index 86b6b3c1..797e7bb0 100644 --- a/src/run/run_environment/gitlab_ci/provider.rs +++ b/src/run/run_environment/gitlab_ci/provider.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use simplelog::SharedLogger; use std::collections::BTreeMap; use std::env; @@ -139,6 +140,7 @@ impl RunEnvironmentDetector for GitLabCIProvider { } } +#[async_trait(?Send)] impl RunEnvironmentProvider for GitLabCIProvider { fn get_logger(&self) -> Box { Box::new(GitLabCILogger::new()) @@ -175,6 +177,18 @@ impl RunEnvironmentProvider for GitLabCIProvider { metadata: BTreeMap::new(), }) } + + /// For GitLab CI, OIDC tokens must be pre-generated and passed via env variable. + /// + /// In [our documentation](https://codspeed.io/docs/integrations/ci/gitlab-ci#openid-connect-oidc-authentication), we ask + /// user to create a variable named `CODSPEED_TOKEN` with the OIDC token. So there is nothing to do here. + /// + /// See: + /// - https://docs.gitlab.com/integration/openid_connect_provider/ + /// - https://docs.gitlab.com/ci/secrets/id_token_authentication/ + async fn set_oidc_token(&self, _config: &mut Config) -> Result<()> { + Ok(()) + } } #[cfg(test)] diff --git a/src/run/run_environment/local/provider.rs b/src/run/run_environment/local/provider.rs index 783c5cf8..db5849e8 100644 --- a/src/run/run_environment/local/provider.rs +++ b/src/run/run_environment/local/provider.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use git2::Repository; use simplelog::SharedLogger; @@ -117,6 +118,7 @@ impl RunEnvironmentDetector for LocalProvider { } } +#[async_trait(?Send)] impl RunEnvironmentProvider for LocalProvider { fn get_repository_provider(&self) -> RepositoryProvider { self.repository_provider.clone() diff --git a/src/run/run_environment/provider.rs b/src/run/run_environment/provider.rs index 6ca9773a..50c48748 100644 --- a/src/run/run_environment/provider.rs +++ b/src/run/run_environment/provider.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use git2::Repository; use simplelog::SharedLogger; @@ -16,8 +17,16 @@ pub trait RunEnvironmentDetector { fn detect() -> bool; } +/// Audience to be used when requesting OIDC tokens. +/// +/// It will be validated when the token is used to authenticate with CodSpeed. +/// +/// This value must match the audience configured in CodSpeed backend. +static OIDC_AUDIENCE: &str = "codspeed.io"; + /// `RunEnvironmentProvider` is a trait that defines the necessary methods /// for a continuous integration provider. +#[async_trait(?Send)] pub trait RunEnvironmentProvider { /// Returns the logger for the RunEnvironment. fn get_logger(&self) -> Box; @@ -34,6 +43,23 @@ pub trait RunEnvironmentProvider { /// Return the metadata necessary to identify the `RunPart` fn get_run_provider_run_part(&self) -> Option; + /// Get the OIDC audience that must be used when requesting OIDC tokens. + /// + /// It will be validated when the token is used to authenticate with CodSpeed. + fn get_oidc_audience(&self) -> &str { + OIDC_AUDIENCE + } + + /// Handle an OIDC token for the current run environment, if supported. + /// + /// Updates the config if necessary. + /// + /// Depending on the provider, this may involve requesting the token, + /// warning the user about potential misconfigurations, or other necessary steps. + async fn set_oidc_token(&self, _config: &mut Config) -> Result<()> { + Ok(()) + } + /// Returns the metadata necessary for uploading results to CodSpeed. /// /// # Arguments