diff --git a/src/controllers/trustpub/tokens/exchange/mod.rs b/src/controllers/trustpub/tokens/exchange/mod.rs index 423521add41..324ebdc7082 100644 --- a/src/controllers/trustpub/tokens/exchange/mod.rs +++ b/src/controllers/trustpub/tokens/exchange/mod.rs @@ -2,7 +2,7 @@ use super::json; use crate::app::AppState; use crate::util::errors::{AppResult, bad_request, server_error}; use axum::Json; -use crates_io_database::models::trustpub::{NewToken, NewUsedJti, TrustpubData}; +use crates_io_database::models::trustpub::{GitHubConfig, NewToken, NewUsedJti, TrustpubData}; use crates_io_database::schema::trustpub_configs_github; use crates_io_diesel_helpers::lower; use crates_io_trustpub::access_token::AccessToken; @@ -38,7 +38,8 @@ pub async fn exchange_trustpub_token( let unverified_issuer = unverified_token_data.claims.iss; let Some(keystore) = state.oidc_key_stores.get(&unverified_issuer) else { - return Err(bad_request("Unsupported JWT issuer")); + let error = format!("Unsupported JWT issuer: {unverified_issuer}"); + return Err(bad_request(error)); }; let Some(unverified_key_id) = unverified_token_data.header.kid else { @@ -60,7 +61,8 @@ pub async fn exchange_trustpub_token( // The following code is only supporting GitHub Actions for now, so let's // drop out if the issuer is not GitHub. if unverified_issuer != GITHUB_ISSUER_URL { - return Err(bad_request("Unsupported JWT issuer")); + let error = format!("Unsupported JWT issuer: {unverified_issuer}"); + return Err(bad_request(error)); } let audience = &state.config.trustpub_audience; @@ -105,29 +107,65 @@ pub async fn exchange_trustpub_token( return Err(bad_request(message)); }; - let crate_ids = trustpub_configs_github::table - .select(trustpub_configs_github::crate_id) - .filter(trustpub_configs_github::repository_owner_id.eq(&repository_owner_id)) + let mut repo_configs = trustpub_configs_github::table + .select(GitHubConfig::as_select()) .filter( lower(trustpub_configs_github::repository_owner).eq(lower(&repository_owner)), ) .filter(lower(trustpub_configs_github::repository_name).eq(lower(&repository_name))) - .filter(trustpub_configs_github::workflow_filename.eq(&workflow_filename)) - .filter( - trustpub_configs_github::environment - .is_null() - .or(lower(trustpub_configs_github::environment) - .eq(lower(&signed_claims.environment))), - ) - .load::(conn) + .load(conn) .await?; - if crate_ids.is_empty() { - warn!("No matching Trusted Publishing config found"); - let message = "No matching Trusted Publishing config found"; + if repo_configs.is_empty() { + let message = format!("No Trusted Publishing config found for repository `{repo}`."); + return Err(bad_request(message)); + } + + let mismatched_owner_ids: Vec = repo_configs + .extract_if(.., |config| config.repository_owner_id != repository_owner_id) + .map(|config| config.repository_owner_id.to_string()) + .collect(); + + if repo_configs.is_empty() { + let message = format!("The Trusted Publishing config for repository `{repo}` does not match the repository owner ID ({repository_owner_id}) in the JWT. Expected owner IDs: {}. Please recreate the Trusted Publishing config to update the repository owner ID.", mismatched_owner_ids.join(", ")); + return Err(bad_request(message)); + } + + let mismatched_workflows: Vec = repo_configs + .extract_if(.., |config| config.workflow_filename != workflow_filename) + .map(|config| format!("`{}`", config.workflow_filename)) + .collect(); + + if repo_configs.is_empty() { + let message = format!("The Trusted Publishing config for repository `{repo}` does not match the workflow filename `{workflow_filename}` in the JWT. Expected workflow filenames: {}", mismatched_workflows.join(", ")); return Err(bad_request(message)); } + let mismatched_environments: Vec = repo_configs + .extract_if(.., |config| { + match (&config.environment, &signed_claims.environment) { + // Keep configs with no environment requirement + (None, _) => false, + // Remove configs requiring environment when JWT has none + (Some(_), None) => true, + // Remove non-matching environments + (Some(config_env), Some(signed_env)) => config_env.to_lowercase() != signed_env.to_lowercase(), + } + }) + .filter_map(|config| config.environment.map(|env| format!("`{env}`"))) + .collect(); + + if repo_configs.is_empty() { + let message = if let Some(signed_environment) = &signed_claims.environment { + format!("The Trusted Publishing config for repository `{repo}` does not match the environment `{signed_environment}` in the JWT. Expected environments: {}", mismatched_environments.join(", ")) + } else { + format!("The Trusted Publishing config for repository `{repo}` requires an environment, but the JWT does not specify one. Expected environments: {}", mismatched_environments.join(", ")) + }; + return Err(bad_request(message)); + } + + let crate_ids = repo_configs.iter().map(|config| config.crate_id).collect::>(); + let new_token = AccessToken::generate(); let trustpub_data = TrustpubData::GitHub { diff --git a/src/controllers/trustpub/tokens/exchange/tests.rs b/src/controllers/trustpub/tokens/exchange/tests.rs index ed7ef69db7a..5e376d14387 100644 --- a/src/controllers/trustpub/tokens/exchange/tests.rs +++ b/src/controllers/trustpub/tokens/exchange/tests.rs @@ -157,7 +157,7 @@ async fn test_unsupported_issuer() -> anyhow::Result<()> { let body = default_claims().as_exchange_body()?; let response = client.post::<()>(URL, body).await; assert_snapshot!(response.status(), @"400 Bad Request"); - assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"Unsupported JWT issuer"}]}"#); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"Unsupported JWT issuer: https://token.actions.githubusercontent.com"}]}"#); Ok(()) } @@ -323,7 +323,7 @@ async fn test_missing_config() -> anyhow::Result<()> { let body = default_claims().as_exchange_body()?; let response = client.post::<()>(URL, body).await; assert_snapshot!(response.status(), @"400 Bad Request"); - assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"No matching Trusted Publishing config found"}]}"#); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"No Trusted Publishing config found for repository `rust-lang/foo-rs`."}]}"#); Ok(()) } @@ -335,7 +335,7 @@ async fn test_missing_environment() -> anyhow::Result<()> { let body = default_claims().as_exchange_body()?; let response = client.post::<()>(URL, body).await; assert_snapshot!(response.status(), @"400 Bad Request"); - assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"No matching Trusted Publishing config found"}]}"#); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"The Trusted Publishing config for repository `rust-lang/foo-rs` requires an environment, but the JWT does not specify one. Expected environments: `prod`"}]}"#); Ok(()) } @@ -350,7 +350,7 @@ async fn test_wrong_environment() -> anyhow::Result<()> { let body = claims.as_exchange_body()?; let response = client.post::<()>(URL, body).await; assert_snapshot!(response.status(), @"400 Bad Request"); - assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"No matching Trusted Publishing config found"}]}"#); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"The Trusted Publishing config for repository `rust-lang/foo-rs` does not match the environment `not-prod` in the JWT. Expected environments: `prod`"}]}"#); Ok(()) }