Skip to content

trustpub: Improve token exchange error messages #11567

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

Merged
merged 2 commits into from
Jul 15, 2025
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
72 changes: 55 additions & 17 deletions src/controllers/trustpub/tokens/exchange/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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::<i32>(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<String> = 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<String> = 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<String> = 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::<Vec<_>>();

let new_token = AccessToken::generate();

let trustpub_data = TrustpubData::GitHub {
Expand Down
8 changes: 4 additions & 4 deletions src/controllers/trustpub/tokens/exchange/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down Expand Up @@ -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(())
}
Expand All @@ -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(())
}
Expand All @@ -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(())
}
Expand Down