diff --git a/crates/crates_io_database/src/models/trustpub/data.rs b/crates/crates_io_database/src/models/trustpub/data.rs index 9b35ece8dc0..6941cdb978d 100644 --- a/crates/crates_io_database/src/models/trustpub/data.rs +++ b/crates/crates_io_database/src/models/trustpub/data.rs @@ -19,6 +19,15 @@ pub enum TrustpubData { /// SHA of the commit sha: String, }, + #[serde(rename = "gitlab")] + GitLab { + /// Project path (e.g. "rust-lang/cargo") + project_path: String, + /// Job ID + job_id: String, + /// SHA of the commit + sha: String, + }, } impl ToSql for TrustpubData { @@ -41,7 +50,7 @@ mod tests { use insta::assert_json_snapshot; #[test] - fn test_serialization() { + fn test_github_serialization() { let data = TrustpubData::GitHub { repository: "octo-org/octo-repo".to_string(), run_id: "example-run-id".to_string(), @@ -57,4 +66,22 @@ mod tests { } "#); } + + #[test] + fn test_gitlab_serialization() { + let data = TrustpubData::GitLab { + project_path: "rust-lang/cargo".to_string(), + job_id: "example-job-id".to_string(), + sha: "example-sha".to_string(), + }; + + assert_json_snapshot!(data, @r#" + { + "provider": "gitlab", + "project_path": "rust-lang/cargo", + "job_id": "example-job-id", + "sha": "example-sha" + } + "#); + } } diff --git a/crates/crates_io_trustpub/src/gitlab/claims.rs b/crates/crates_io_trustpub/src/gitlab/claims.rs new file mode 100644 index 00000000000..46c89c93d03 --- /dev/null +++ b/crates/crates_io_trustpub/src/gitlab/claims.rs @@ -0,0 +1,453 @@ +use crate::gitlab::GITLAB_ISSUER_URL; +use crate::gitlab::workflows::extract_workflow_filepath; +use chrono::serde::ts_seconds; +use chrono::{DateTime, Utc}; +use jsonwebtoken::errors::{Error, ErrorKind}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation}; + +/// Claims extracted from a GitLab CI OIDC token. +/// +/// This struct is used to decode and validate the JWT token generated by +/// GitLab CI. It contains the claims that are relevant for our "Trusted +/// Publishing" implementation. +/// +/// See . +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct GitLabClaims { + pub aud: String, + #[serde(with = "ts_seconds")] + pub iat: DateTime, + #[serde(with = "ts_seconds")] + pub exp: DateTime, + pub jti: String, + + pub namespace_id: String, + pub project_path: String, + pub ci_config_ref_uri: String, + pub environment: Option, + pub job_id: String, + pub sha: String, +} + +impl GitLabClaims { + /// Decode and validate a JWT token, returning the relevant claims if valid. + pub fn decode(token: &str, audience: &str, key: &DecodingKey) -> Result { + let validation = validation(audience); + + let claims: Self = jsonwebtoken::decode(token, key, &validation)?.claims; + + let leeway = chrono::TimeDelta::seconds(validation.leeway as i64); + if claims.iat > Utc::now() + leeway { + return Err(ErrorKind::ImmatureSignature.into()); + } + + Ok(claims) + } + + /// Extract the workflow filename from the [`workflow_ref`](Self::workflow_ref) + /// field or return `None` if the filename cannot be extracted. + pub fn workflow_filepath(&self) -> Option<&str> { + extract_workflow_filepath(&self.ci_config_ref_uri) + } +} + +fn validation(audience: &str) -> Validation { + let mut validation = Validation::new(Algorithm::RS256); + validation.required_spec_claims.insert("iss".into()); + validation.required_spec_claims.insert("exp".into()); + validation.required_spec_claims.insert("aud".into()); + validation.validate_exp = true; + validation.validate_aud = true; + validation.validate_nbf = true; + validation.set_issuer(&[GITLAB_ISSUER_URL]); + validation.set_audience(&[audience]); + validation +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_keys::{DECODING_KEY, encode_for_testing}; + use insta::{assert_compact_debug_snapshot, assert_json_snapshot}; + use serde_json::json; + use std::time::SystemTime; + + const AUDIENCE: &str = "crates.io"; + + #[test] + fn test_decode() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "namespace_id": "72", + "namespace_path": "my-group", + "project_id": "20", + "project_path": "my-group/my-project", + "user_id": "1", + "user_login": "sample-user", + "user_email": "sample-user@example.com", + "user_identities": [ + {"provider": "github", "extern_uid": "2435223452345"}, + {"provider": "bitbucket", "extern_uid": "john.smith"} + ], + "pipeline_id": "574", + "pipeline_source": "push", + "job_id": "302", + "ref": "feature-branch-1", + "ref_type": "branch", + "ref_path": "refs/heads/feature-branch-1", + "ref_protected": "false", + "groups_direct": ["mygroup/mysubgroup", "myothergroup/myothersubgroup"], + "environment": "test-environment2", + "environment_protected": "false", + "deployment_tier": "testing", + "environment_action": "start", + "runner_id": 1, + "runner_environment": "self-hosted", + "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc", + "project_visibility": "public", + "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main", + "ci_config_sha": "714a629c0b401fdce83e847fc9589983fc6f46bc", + "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b", + "iss": "https://gitlab.com", + "iat": now, + "nbf": now - 5, + "exp": now + 60 * 60, + "sub": "project_path:my-group/my-project:ref_type:branch:ref:feature-branch-1", + "aud": AUDIENCE + }))?; + + let claims = GitLabClaims::decode(&jwt, AUDIENCE, &DECODING_KEY)?; + assert_json_snapshot!(claims, { ".iat" => "[datetime]", ".exp" => "[datetime]" }, @r#" + { + "aud": "crates.io", + "iat": "[datetime]", + "exp": "[datetime]", + "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b", + "namespace_id": "72", + "project_path": "my-group/my-project", + "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main", + "environment": "test-environment2", + "job_id": "302", + "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc" + } + "#); + + Ok(()) + } + + #[test] + fn test_decode_minimal() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "namespace_id": "72", + "project_path": "my-group/my-project", + "job_id": "302", + "environment": "test-environment2", + "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc", + "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main", + "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b", + "iss": "https://gitlab.com", + "iat": now, + "exp": now + 60 * 60, + "aud": AUDIENCE + }))?; + + let claims = GitLabClaims::decode(&jwt, AUDIENCE, &DECODING_KEY)?; + assert_json_snapshot!(claims, { ".iat" => "[datetime]", ".exp" => "[datetime]" }, @r#" + { + "aud": "crates.io", + "iat": "[datetime]", + "exp": "[datetime]", + "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b", + "namespace_id": "72", + "project_path": "my-group/my-project", + "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main", + "environment": "test-environment2", + "job_id": "302", + "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc" + } + "#); + + Ok(()) + } + + #[test] + fn test_decode_missing_jti() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "namespace_id": "72", + "project_path": "my-group/my-project", + "job_id": "302", + "environment": "test-environment2", + "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc", + "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main", + "iss": "https://gitlab.com", + "iat": now, + "exp": now + 60 * 60, + "aud": AUDIENCE + }))?; + + let error = GitLabClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `jti`", line: 1, column: 328)))"#); + + Ok(()) + } + + #[test] + fn test_decode_wrong_audience() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "namespace_id": "72", + "project_path": "my-group/my-project", + "job_id": "302", + "environment": "test-environment2", + "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc", + "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main", + "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b", + "iss": "https://gitlab.com", + "iat": now, + "exp": now + 60 * 60, + "aud": "somebody-else" + }))?; + + let error = GitLabClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @"Error(InvalidAudience)"); + + Ok(()) + } + + #[test] + fn test_decode_multi_audience() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "namespace_id": "72", + "project_path": "my-group/my-project", + "job_id": "302", + "environment": "test-environment2", + "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc", + "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main", + "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b", + "iss": "https://gitlab.com", + "iat": now, + "exp": now + 60 * 60, + "aud": [AUDIENCE, "somebody-else"] + }))?; + + let error = GitLabClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("invalid type: sequence, expected a string", line: 1, column: 7)))"#); + + Ok(()) + } + #[test] + fn test_decode_missing_project_path() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "namespace_id": "72", + "job_id": "302", + "environment": "test-environment2", + "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc", + "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main", + "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b", + "iss": "https://gitlab.com", + "iat": now, + "exp": now + 60 * 60, + "aud": AUDIENCE + }))?; + + let error = GitLabClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `project_path`", line: 1, column: 336)))"#); + + Ok(()) + } + #[test] + fn test_decode_missing_namespace_id() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "project_path": "my-group/my-project", + "job_id": "302", + "environment": "test-environment2", + "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc", + "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main", + "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b", + "iss": "https://gitlab.com", + "iat": now, + "exp": now + 60 * 60, + "aud": AUDIENCE + }))?; + + let error = GitLabClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `namespace_id`", line: 1, column: 353)))"#); + + Ok(()) + } + #[test] + fn test_decode_missing_workflow() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "namespace_id": "72", + "project_path": "my-group/my-project", + "job_id": "302", + "environment": "test-environment2", + "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc", + "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b", + "iss": "https://gitlab.com", + "iat": now, + "exp": now + 60 * 60, + "aud": AUDIENCE + }))?; + + let error = GitLabClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `ci_config_ref_uri`", line: 1, column: 280)))"#); + + Ok(()) + } + + #[test] + fn test_decode_missing_issuer() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "namespace_id": "72", + "project_path": "my-group/my-project", + "job_id": "302", + "environment": "test-environment2", + "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc", + "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main", + "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b", + "iat": now, + "exp": now + 60 * 60, + "aud": AUDIENCE + }))?; + + let error = GitLabClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(MissingRequiredClaim("iss"))"#); + + Ok(()) + } + + #[test] + fn test_decode_wrong_issuer() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "namespace_id": "72", + "project_path": "my-group/my-project", + "job_id": "302", + "environment": "test-environment2", + "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc", + "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main", + "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b", + "iss": "https://github.com", + "iat": now, + "exp": now + 60 * 60, + "aud": AUDIENCE + }))?; + + let error = GitLabClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @"Error(InvalidIssuer)"); + + Ok(()) + } + + #[test] + fn test_decode_missing_exp() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "namespace_id": "72", + "project_path": "my-group/my-project", + "job_id": "302", + "environment": "test-environment2", + "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc", + "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main", + "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b", + "iss": "https://gitlab.com", + "iat": now, + "aud": AUDIENCE + }))?; + + let error = GitLabClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `exp`", line: 1, column: 356)))"#); + + Ok(()) + } + + #[test] + fn test_decode_expired() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "namespace_id": "72", + "project_path": "my-group/my-project", + "job_id": "302", + "environment": "test-environment2", + "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc", + "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main", + "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b", + "iss": "https://gitlab.com", + "exp": now - 3000, + "iat": now - 6000, + "aud": AUDIENCE + }))?; + + let error = GitLabClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @"Error(ExpiredSignature)"); + + Ok(()) + } + + #[test] + fn test_decode_missing_iat() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "namespace_id": "72", + "project_path": "my-group/my-project", + "job_id": "302", + "environment": "test-environment2", + "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc", + "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main", + "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b", + "iss": "https://gitlab.com", + "exp": now + 60 * 60, + "aud": AUDIENCE + }))?; + + let error = GitLabClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `iat`", line: 1, column: 356)))"#); + + Ok(()) + } + + #[test] + fn test_decode_future_iat() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "namespace_id": "72", + "project_path": "my-group/my-project", + "job_id": "302", + "environment": "test-environment2", + "sha": "714a629c0b401fdce83e847fc9589983fc6f46bc", + "ci_config_ref_uri": "gitlab.example.com/my-group/my-project//.gitlab-ci.yml@refs/heads/main", + "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b", + "iss": "https://gitlab.com", + "exp": now + 300, + "iat": now + 100, + "aud": AUDIENCE + }))?; + + let error = GitLabClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @"Error(ImmatureSignature)"); + + Ok(()) + } +} diff --git a/crates/crates_io_trustpub/src/gitlab/mod.rs b/crates/crates_io_trustpub/src/gitlab/mod.rs index 5826f5a48bb..d2b5fd4ec38 100644 --- a/crates/crates_io_trustpub/src/gitlab/mod.rs +++ b/crates/crates_io_trustpub/src/gitlab/mod.rs @@ -1 +1,8 @@ +mod claims; +#[cfg(any(test, feature = "test-helpers"))] +pub mod test_helpers; +mod workflows; + +pub use self::claims::GitLabClaims; + pub const GITLAB_ISSUER_URL: &str = "https://gitlab.com"; diff --git a/crates/crates_io_trustpub/src/gitlab/snapshots/crates_io_trustpub__gitlab__test_helpers__tests__gitlab_claims.snap b/crates/crates_io_trustpub/src/gitlab/snapshots/crates_io_trustpub__gitlab__test_helpers__tests__gitlab_claims.snap new file mode 100644 index 00000000000..94ad1d616a0 --- /dev/null +++ b/crates/crates_io_trustpub/src/gitlab/snapshots/crates_io_trustpub__gitlab__test_helpers__tests__gitlab_claims.snap @@ -0,0 +1,38 @@ +--- +source: crates/crates_io_trustpub/src/gitlab/test_helpers.rs +expression: claims +--- +{ + "iss": "https://gitlab.com", + "nbf": "[timestamp]", + "exp": "[timestamp]", + "iat": "[timestamp]", + "jti": "example-id", + "sub": "project_path:octocat/hello-world:ref_type:branch:ref:main", + "aud": "crates.io", + "project_id": "74884433", + "project_path": "octocat/hello-world", + "namespace_id": "123", + "namespace_path": "octocat", + "user_id": "39035", + "user_login": "octocat", + "user_email": "foo@bar.cloud", + "user_access_level": "owner", + "job_project_id": "74884433", + "job_project_path": "octocat/hello-world", + "job_namespace_id": "123", + "job_namespace_path": "octocat", + "pipeline_id": "2069090987", + "pipeline_source": "push", + "job_id": "11530106120", + "ref": "main", + "ref_type": "branch", + "ref_path": "refs/heads/main", + "ref_protected": "true", + "runner_id": 12270840, + "runner_environment": "gitlab-hosted", + "sha": "76719c2658b5c4423810d655a4624af1b38b7091", + "project_visibility": "public", + "ci_config_ref_uri": "gitlab.com/octocat/hello-world//.gitlab-ci.yml@refs/heads/main", + "ci_config_sha": "76719c2658b5c4423810d655a4624af1b38b7091" +} diff --git a/crates/crates_io_trustpub/src/gitlab/test_helpers.rs b/crates/crates_io_trustpub/src/gitlab/test_helpers.rs new file mode 100644 index 00000000000..f1fe55ba118 --- /dev/null +++ b/crates/crates_io_trustpub/src/gitlab/test_helpers.rs @@ -0,0 +1,149 @@ +use super::GITLAB_ISSUER_URL; +use crate::test_keys::encode_for_testing; +use bon::bon; +use serde_json::json; + +pub const AUDIENCE: &str = "crates.io"; + +/// A struct representing all the claims in a GitLab CI OIDC token. +/// +/// This struct is used to create a JWT for testing purposes. +#[derive(Debug, serde::Serialize)] +pub struct FullGitLabClaims { + pub iss: String, + pub nbf: i64, + pub exp: i64, + pub iat: i64, + pub jti: String, + pub sub: String, + pub aud: String, + + pub project_id: String, + pub project_path: String, + pub namespace_id: String, + pub namespace_path: String, + pub user_id: String, + pub user_login: String, + pub user_email: String, + pub user_access_level: String, + pub job_project_id: String, + pub job_project_path: String, + pub job_namespace_id: String, + pub job_namespace_path: String, + pub pipeline_id: String, + pub pipeline_source: String, + pub job_id: String, + #[serde(rename = "ref")] + pub r#ref: String, + pub ref_type: String, + pub ref_path: String, + pub ref_protected: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub environment: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub environment_protected: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deployment_tier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub environment_action: Option, + pub runner_id: i64, + pub runner_environment: String, + pub sha: String, + pub project_visibility: String, + pub ci_config_ref_uri: String, + pub ci_config_sha: String, +} + +#[bon] +impl FullGitLabClaims { + #[builder] + pub fn new( + namespace_id: &str, + namespace: &str, + project: &str, + workflow_filepath: &str, + environment: Option<&str>, + ) -> Self { + let now = chrono::Utc::now().timestamp(); + + Self { + iss: GITLAB_ISSUER_URL.into(), + nbf: now, + iat: now, + exp: now + 60 * 60, + jti: "example-id".into(), + sub: format!("project_path:{namespace}/{project}:ref_type:branch:ref:main"), + aud: AUDIENCE.into(), + + project_id: "74884433".into(), + project_path: format!("{namespace}/{project}"), + namespace_id: namespace_id.into(), + namespace_path: namespace.into(), + user_id: "39035".into(), + user_login: namespace.into(), + user_email: "foo@bar.cloud".into(), + user_access_level: "owner".into(), + job_project_id: "74884433".into(), + job_project_path: format!("{namespace}/{project}"), + job_namespace_id: namespace_id.into(), + job_namespace_path: namespace.into(), + pipeline_id: "2069090987".into(), + pipeline_source: "push".into(), + job_id: "11530106120".into(), + r#ref: "main".into(), + ref_type: "branch".into(), + ref_path: "refs/heads/main".into(), + ref_protected: "true".into(), + environment: environment.map(|s| s.into()), + environment_protected: environment.map(|_| "false".into()), + deployment_tier: environment.map(|_| "other".into()), + environment_action: environment.map(|_| "start".into()), + runner_id: 12270840, + runner_environment: "gitlab-hosted".into(), + sha: "76719c2658b5c4423810d655a4624af1b38b7091".into(), + project_visibility: "public".into(), + ci_config_ref_uri: format!( + "gitlab.com/{namespace}/{project}//{workflow_filepath}@refs/heads/main" + ), + ci_config_sha: "76719c2658b5c4423810d655a4624af1b38b7091".into(), + } + } + + pub fn encoded(&self) -> anyhow::Result { + Ok(encode_for_testing(self)?) + } + + pub fn as_exchange_body(&self) -> anyhow::Result { + let jwt = self.encoded()?; + Ok(serde_json::to_string(&json!({ "jwt": jwt }))?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use claims::assert_ok; + use insta::assert_json_snapshot; + + #[test] + fn test_gitlab_claims() { + let claims = FullGitLabClaims::builder() + .namespace_id("123") + .namespace("octocat") + .project("hello-world") + .workflow_filepath(".gitlab-ci.yml") + .build(); + + assert_json_snapshot!(claims, { + ".nbf" => "[timestamp]", + ".iat" => "[timestamp]", + ".exp" => "[timestamp]", + }); + + let encoded = assert_ok!(claims.encoded()); + assert!(!encoded.is_empty()); + + let exchange_body = assert_ok!(claims.as_exchange_body()); + assert!(exchange_body.contains(&encoded)); + } +} diff --git a/crates/crates_io_trustpub/src/gitlab/workflows.rs b/crates/crates_io_trustpub/src/gitlab/workflows.rs new file mode 100644 index 00000000000..54fe6b7a568 --- /dev/null +++ b/crates/crates_io_trustpub/src/gitlab/workflows.rs @@ -0,0 +1,106 @@ +/// Extracts the workflow path from a GitLab `ci_config_ref_uri` claim. +/// +/// In other words, it turns e.g. `gitlab.com/rust-lang/regex//foo/bar/baz.yml@refs/heads/main` +/// into `foo/bar/baz.yml`, or `None` if the reference is in an unexpected format. +/// +/// This was initially using a regular expression (`//(.*[^/]\.(yml|yaml))@.+`), +/// but was changed to string operations to avoid potential ReDoS attack vectors +/// (see `test_extract_workflow_filename_redos` test below). +pub(crate) fn extract_workflow_filepath(workflow_ref: &str) -> Option<&str> { + // Find the double slash that separates project path from workflow path + let start = workflow_ref.find("//")?; + let after_double_slash = &workflow_ref[start + 2..]; + + // Find the last @ that separates workflow path from ref + let end = after_double_slash.rfind('@')?; + let filepath = &after_double_slash[..end]; + + // Validate: must end with .yml or .yaml + if !filepath.ends_with(".yml") && !filepath.ends_with(".yaml") { + return None; + } + + // Get the basename (part after last slash, or whole string if no slash) + let basename = filepath.rsplit('/').next()?; + + // Basename must not start with a dot (rejects ".yml", ".yaml", "somedir/.yaml") + if basename.starts_with('.') { + return None; + } + + Some(filepath) +} + +#[cfg(test)] +mod tests { + #[test] + fn test_extract_workflow_filename() { + let test_cases = [ + // Well-formed `ci_config_ref_uri`s, including obnoxious ones. + ( + "gitlab.com/foo/bar//notnested.yml@/some/ref", + Some("notnested.yml"), + ), + ( + "gitlab.com/foo/bar//notnested.yaml@/some/ref", + Some("notnested.yaml"), + ), + ( + "gitlab.com/foo/bar//basic/basic.yml@/some/ref", + Some("basic/basic.yml"), + ), + ( + "gitlab.com/foo/bar//more/nested/example.yml@/some/ref", + Some("more/nested/example.yml"), + ), + ( + "gitlab.com/foo/bar//too//many//slashes.yml@/some/ref", + Some("too//many//slashes.yml"), + ), + ("gitlab.com/foo/bar//has-@.yml@/some/ref", Some("has-@.yml")), + ( + "gitlab.com/foo/bar//foo.bar.yml@/some/ref", + Some("foo.bar.yml"), + ), + ( + "gitlab.com/foo/bar//foo.yml.bar.yml@/some/ref", + Some("foo.yml.bar.yml"), + ), + ( + "gitlab.com/foo/bar//foo.yml@bar.yml@/some/ref", + Some("foo.yml@bar.yml"), + ), + ( + "gitlab.com/foo/bar//@foo.yml@bar.yml@/some/ref", + Some("@foo.yml@bar.yml"), + ), + ( + "gitlab.com/foo/bar//@.yml.foo.yml@bar.yml@/some/ref", + Some("@.yml.foo.yml@bar.yml"), + ), + ("gitlab.com/foo/bar//a.yml@refs/heads/main", Some("a.yml")), + ( + "gitlab.com/foo/bar//a/b.yml@refs/heads/main", + Some("a/b.yml"), + ), + // Malformed `ci_config_ref_uri`s. + ("gitlab.com/foo/bar//notnested.wrongsuffix@/some/ref", None), + ("gitlab.com/foo/bar//@/some/ref", None), + ("gitlab.com/foo/bar//.yml@/some/ref", None), + ("gitlab.com/foo/bar//.yaml@/some/ref", None), + ("gitlab.com/foo/bar//somedir/.yaml@/some/ref", None), + ]; + + for (input, expected) in test_cases { + let result = super::extract_workflow_filepath(input); + assert_eq!(result, expected, "Input: {input}"); + } + } + + #[test] + fn test_extract_workflow_filename_redos() { + let _ = super::extract_workflow_filepath( + &(".yml@//".repeat(200_000_000) + ".yml@/\n//\x00.yml@y"), + ); + } +} diff --git a/src/app.rs b/src/app.rs index 9bf956a6d89..8deca9330a0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -13,6 +13,7 @@ use axum::extract::{FromRef, FromRequestParts, State}; use bon::Builder; use crates_io_github::GitHubClient; use crates_io_trustpub::github::GITHUB_ISSUER_URL; +use crates_io_trustpub::gitlab::GITLAB_ISSUER_URL; use crates_io_trustpub::keystore::{OidcKeyStore, RealOidcKeyStore}; use deadpool_diesel::Runtime; use derive_more::Deref; @@ -117,6 +118,10 @@ impl AppBuilder { let key_store = RealOidcKeyStore::new(GITHUB_ISSUER_URL.into()); key_stores.insert(GITHUB_ISSUER_URL.into(), Box::new(key_store)); } + "gitlab" => { + let key_store = RealOidcKeyStore::new(GITLAB_ISSUER_URL.into()); + key_stores.insert(GITLAB_ISSUER_URL.into(), Box::new(key_store)); + } provider => { warn!("Unknown Trusted Publishing provider: {provider}"); } diff --git a/src/controllers/trustpub/tokens/exchange/gitlab_tests.rs b/src/controllers/trustpub/tokens/exchange/gitlab_tests.rs new file mode 100644 index 00000000000..91fc665c7da --- /dev/null +++ b/src/controllers/trustpub/tokens/exchange/gitlab_tests.rs @@ -0,0 +1,496 @@ +use crate::tests::builders::CrateBuilder; +use crate::tests::util::{MockAnonymousUser, RequestHelper, TestApp}; +use claims::{assert_ok, assert_some_eq}; +use crates_io_database::models::trustpub::{GitLabConfig, NewGitLabConfig}; +use crates_io_database::schema::{trustpub_configs_gitlab, trustpub_tokens}; +use crates_io_trustpub::access_token::AccessToken; +use crates_io_trustpub::gitlab::GITLAB_ISSUER_URL; +use crates_io_trustpub::gitlab::test_helpers::FullGitLabClaims; +use crates_io_trustpub::keystore::MockOidcKeyStore; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use insta::{assert_compact_debug_snapshot, assert_json_snapshot, assert_snapshot}; +use jsonwebtoken::{EncodingKey, Header}; +use mockall::predicate::*; +use serde_json::json; + +const URL: &str = "/api/v1/trusted_publishing/tokens"; + +const CRATE_NAME: &str = "foo"; +const NAMESPACE: &str = "rust-lang"; +const NAMESPACE_ID: &str = "42"; +const PROJECT: &str = "foo-rs"; +const WORKFLOW_FILEPATH: &str = "some/subfolder/jobs.yaml"; + +async fn prepare() -> anyhow::Result { + prepare_with_config(|_config| {}).await +} + +async fn prepare_with_config( + adjust_config: fn(&mut NewGitLabConfig<'static>), +) -> anyhow::Result { + let (app, client, cookie) = TestApp::full() + .with_oidc_keystore(GITLAB_ISSUER_URL, MockOidcKeyStore::with_test_key()) + .with_user() + .await; + + let mut conn = app.db_conn().await; + + let owner_id = cookie.as_model().id; + let krate = CrateBuilder::new(CRATE_NAME, owner_id) + .build(&mut conn) + .await?; + + let mut new_oidc_config = new_oidc_config(krate.id); + adjust_config(&mut new_oidc_config); + new_oidc_config.insert(&mut conn).await?; + + Ok(client) +} + +fn new_oidc_config(crate_id: i32) -> NewGitLabConfig<'static> { + NewGitLabConfig { + crate_id, + namespace: NAMESPACE, + project: PROJECT, + workflow_filepath: WORKFLOW_FILEPATH, + environment: None, + } +} + +fn default_claims() -> FullGitLabClaims { + FullGitLabClaims::builder() + .namespace_id(NAMESPACE_ID) + .namespace(NAMESPACE) + .project(PROJECT) + .workflow_filepath(WORKFLOW_FILEPATH) + .build() +} + +// ============================================================================ +// Success cases +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_happy_path() -> anyhow::Result<()> { + let client = prepare().await?; + + let body = default_claims().as_exchange_body()?; + let response = client.post::<()>(URL, body).await; + assert_snapshot!(response.status(), @"200 OK"); + + let json = response.json(); + assert_json_snapshot!(json, { ".token" => "[token]" }, @r#" + { + "token": "[token]" + } + "#); + + let token = json["token"].as_str().unwrap(); + let token = assert_ok!(token.parse::()); + let hashed_token = token.sha256(); + + let mut conn = client.app().db_conn().await; + + let tokens = trustpub_tokens::table + .filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice())) + .select((trustpub_tokens::id, trustpub_tokens::crate_ids)) + .get_results::<(i64, Vec>)>(&mut conn) + .await?; + + assert_eq!(tokens.len(), 1); + assert_compact_debug_snapshot!(tokens, @"[(1, [Some(1)])]"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_happy_path_with_environment() -> anyhow::Result<()> { + let client = prepare_with_config(|c| c.environment = Some("prod")).await?; + + let mut claims = default_claims(); + claims.environment = Some("prod".into()); + + let body = claims.as_exchange_body()?; + let response = client.post::<()>(URL, body).await; + assert_snapshot!(response.status(), @"200 OK"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_happy_path_with_ignored_environment() -> anyhow::Result<()> { + let client = prepare().await?; + + let mut claims = default_claims(); + claims.environment = Some("prod".into()); + + let body = claims.as_exchange_body()?; + let response = client.post::<()>(URL, body).await; + assert_snapshot!(response.status(), @"200 OK"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_case_insensitive() -> anyhow::Result<()> { + let client = prepare_with_config(|c| c.environment = Some("Prod")).await?; + + let claims = FullGitLabClaims::builder() + .namespace_id(NAMESPACE_ID) + .namespace("RUST-lanG") + .project("foo-RS") + .workflow_filepath(WORKFLOW_FILEPATH) + .environment("PROD") + .build(); + + let body = claims.as_exchange_body()?; + let response = client.post::<()>(URL, body).await; + assert_snapshot!(response.status(), @"200 OK"); + + Ok(()) +} + +// ============================================================================ +// JWT decode and validation tests +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_broken_jwt() -> anyhow::Result<()> { + let client = prepare().await?; + + let body = serde_json::to_vec(&json!({ "jwt": "broken" }))?; + let response = client.post::<()>(URL, body).await; + assert_snapshot!(response.status(), @"400 Bad Request"); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"Failed to decode JWT"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_unsupported_issuer() -> anyhow::Result<()> { + let (app, client, cookie) = TestApp::full().with_user().await; + + let mut conn = app.db_conn().await; + + let owner_id = cookie.as_model().id; + let krate = CrateBuilder::new(CRATE_NAME, owner_id) + .build(&mut conn) + .await?; + + new_oidc_config(krate.id).insert(&mut conn).await?; + + 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: https://gitlab.com"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_missing_key_id() -> anyhow::Result<()> { + let client = prepare().await?; + + let claims = default_claims(); + let secret_key = EncodingKey::from_secret(b"secret"); + let jwt = jsonwebtoken::encode(&Header::default(), &claims, &secret_key)?; + let body = serde_json::to_vec(&json!({ "jwt": jwt }))?; + + let response = client.post::<()>(URL, body).await; + assert_snapshot!(response.status(), @"400 Bad Request"); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"Missing JWT key ID"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_unknown_key() -> anyhow::Result<()> { + let mut mock_key_store = MockOidcKeyStore::default(); + + mock_key_store + .expect_get_oidc_key() + .with(always()) + .returning(|_| Ok(None)); + + let (app, client, cookie) = TestApp::full() + .with_oidc_keystore(GITLAB_ISSUER_URL, mock_key_store) + .with_user() + .await; + + let mut conn = app.db_conn().await; + + let owner_id = cookie.as_model().id; + let krate = CrateBuilder::new("foo", owner_id).build(&mut conn).await?; + new_oidc_config(krate.id).insert(&mut conn).await?; + + 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":"Invalid JWT key ID"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_key_store_error() -> anyhow::Result<()> { + let mut mock_key_store = MockOidcKeyStore::default(); + + mock_key_store + .expect_get_oidc_key() + .with(always()) + .returning(|_| Err(anyhow::anyhow!("Failed to load OIDC key set"))); + + let (app, client, cookie) = TestApp::full() + .with_oidc_keystore(GITLAB_ISSUER_URL, mock_key_store) + .with_user() + .await; + + let mut conn = app.db_conn().await; + + let owner_id = cookie.as_model().id; + let krate = CrateBuilder::new("foo", owner_id).build(&mut conn).await?; + new_oidc_config(krate.id).insert(&mut conn).await?; + + let body = default_claims().as_exchange_body()?; + let response = client.post::<()>(URL, body).await; + assert_snapshot!(response.status(), @"500 Internal Server Error"); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"Failed to load OIDC key set"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_audience() -> anyhow::Result<()> { + let client = prepare().await?; + + let mut claims = default_claims(); + claims.aud = "invalid-audience".into(); + + 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":"Failed to decode JWT"}]}"#); + + Ok(()) +} + +// ============================================================================ +// JTI replay prevention tests +// ============================================================================ + +/// Test that OIDC tokens can only be exchanged once +#[tokio::test(flavor = "multi_thread")] +async fn test_token_reuse() -> anyhow::Result<()> { + let client = prepare().await?; + + let body = default_claims().as_exchange_body()?; + + // The first exchange should succeed + let response = client.post::<()>(URL, body.clone()).await; + assert_snapshot!(response.status(), @"200 OK"); + + // The second exchange should fail + let response = client.post::<()>(URL, body).await; + assert_snapshot!(response.status(), @"400 Bad Request"); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"JWT has already been used"}]}"#); + + Ok(()) +} + +// ============================================================================ +// Project path parsing tests +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_project_path() -> anyhow::Result<()> { + let client = prepare().await?; + + let mut claims = default_claims(); + claims.project_path = "what?".into(); + + 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":"Unexpected `project_path` value"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_project_path_no_slash() -> anyhow::Result<()> { + let client = prepare().await?; + + let mut claims = default_claims(); + claims.project_path = "invalid-no-slash".into(); + + 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":"Unexpected `project_path` value"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_subgroup_project_path() -> anyhow::Result<()> { + let client = prepare_with_config(|c| { + c.namespace = "group/subgroup"; + c.project = "project"; + }) + .await?; + + let claims = FullGitLabClaims::builder() + .namespace_id(NAMESPACE_ID) + .namespace("group/subgroup") + .project("project") + .workflow_filepath(WORKFLOW_FILEPATH) + .build(); + + let body = claims.as_exchange_body()?; + let response = client.post::<()>(URL, body).await; + assert_snapshot!(response.status(), @"200 OK"); + + Ok(()) +} + +// ============================================================================ +// Workflow filepath extraction tests +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_ci_config_ref_uri() -> anyhow::Result<()> { + let client = prepare().await?; + + let mut claims = default_claims(); + claims.ci_config_ref_uri = "what?".into(); + + 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":"Unexpected `ci_config_ref_uri` value"}]}"#); + + Ok(()) +} + +// ============================================================================ +// Config lookup tests +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_missing_config() -> anyhow::Result<()> { + let (_app, client, _cookie) = TestApp::full() + .with_oidc_keystore(GITLAB_ISSUER_URL, MockOidcKeyStore::with_test_key()) + .with_user() + .await; + + 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 Trusted Publishing config found for repository `rust-lang/foo-rs`."}]}"#); + + Ok(()) +} + +// ============================================================================ +// Namespace ID lazy population and resurrection protection tests +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_lazy_namespace_id_population() -> anyhow::Result<()> { + let client = prepare().await?; + let mut conn = client.app().db_conn().await; + + // First exchange should succeed and populate namespace_id + let body = default_claims().as_exchange_body()?; + let response = client.post::<()>(URL, body).await; + assert_snapshot!(response.status(), @"200 OK"); + + // Check that namespace_id was populated in the database + let config: GitLabConfig = trustpub_configs_gitlab::table + .filter(trustpub_configs_gitlab::namespace.eq(NAMESPACE)) + .filter(trustpub_configs_gitlab::project.eq(PROJECT)) + .first(&mut conn) + .await?; + + assert_some_eq!(config.namespace_id, NAMESPACE_ID); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_namespace_id_mismatch_resurrection_attack() -> anyhow::Result<()> { + // Create a config with a pre-populated namespace_id + let client = prepare().await?; + + let mut conn = client.app().db_conn().await; + + // Manually update the config to have a different namespace_id + diesel::update( + trustpub_configs_gitlab::table + .filter(trustpub_configs_gitlab::namespace.eq(NAMESPACE)) + .filter(trustpub_configs_gitlab::project.eq(PROJECT)), + ) + .set(trustpub_configs_gitlab::namespace_id.eq("999")) + .execute(&mut conn) + .await?; + + // Try to exchange with different namespace_id - should fail + 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":"The Trusted Publishing config for repository `rust-lang/foo-rs` does not match the namespace ID (42) in the JWT. Expected namespace IDs: 999. Please recreate the Trusted Publishing config to update the namespace ID."}]}"#); + + Ok(()) +} + +// ============================================================================ +// Workflow filepath matching tests +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_wrong_workflow_filepath() -> anyhow::Result<()> { + let client = prepare().await?; + + let mut claims = default_claims(); + claims.ci_config_ref_uri = + "gitlab.com/rust-lang/foo-rs//wrong-workflow.yml@refs/heads/main".into(); + + 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":"The Trusted Publishing config for repository `rust-lang/foo-rs` does not match the workflow filepath `wrong-workflow.yml` in the JWT. Expected workflow filepaths: `some/subfolder/jobs.yaml`"}]}"#); + + Ok(()) +} + +// ============================================================================ +// Environment matching tests +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_missing_environment() -> anyhow::Result<()> { + let client = prepare_with_config(|c| c.environment = Some("prod")).await?; + + 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":"The Trusted Publishing config for repository `rust-lang/foo-rs` requires an environment, but the JWT does not specify one. Expected environments: `prod`"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_wrong_environment() -> anyhow::Result<()> { + let client = prepare_with_config(|c| c.environment = Some("prod")).await?; + + let mut claims = default_claims(); + claims.environment = Some("not-prod".into()); + + 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":"The Trusted Publishing config for repository `rust-lang/foo-rs` does not match the environment `not-prod` in the JWT. Expected environments: `prod`"}]}"#); + + Ok(()) +} diff --git a/src/controllers/trustpub/tokens/exchange/mod.rs b/src/controllers/trustpub/tokens/exchange/mod.rs index 292bc113cc3..ffbd04cca8f 100644 --- a/src/controllers/trustpub/tokens/exchange/mod.rs +++ b/src/controllers/trustpub/tokens/exchange/mod.rs @@ -3,11 +3,14 @@ use crate::app::AppState; use crate::util::errors::{AppResult, BoxedAppError, bad_request, server_error}; use axum::Json; use chrono::{DateTime, Utc}; -use crates_io_database::models::trustpub::{GitHubConfig, NewToken, NewUsedJti, TrustpubData}; -use crates_io_database::schema::trustpub_configs_github; +use crates_io_database::models::trustpub::{ + GitHubConfig, GitLabConfig, NewToken, NewUsedJti, TrustpubData, +}; +use crates_io_database::schema::{trustpub_configs_github, trustpub_configs_gitlab}; use crates_io_diesel_helpers::lower; use crates_io_trustpub::access_token::AccessToken; use crates_io_trustpub::github::{GITHUB_ISSUER_URL, GitHubClaims}; +use crates_io_trustpub::gitlab::{GITLAB_ISSUER_URL, GitLabClaims}; use crates_io_trustpub::keystore::DecodingKey; use crates_io_trustpub::unverified::UnverifiedClaims; use diesel::prelude::*; @@ -19,6 +22,8 @@ use tracing::warn; #[cfg(test)] mod github_tests; +#[cfg(test)] +mod gitlab_tests; /// Exchange an OIDC token for a temporary access token. #[utoipa::path( @@ -60,6 +65,7 @@ pub async fn exchange_trustpub_token( match unverified_issuer.as_str() { GITHUB_ISSUER_URL => handle_github_token(&state, &unverified_jwt, &key).await, + GITLAB_ISSUER_URL => handle_gitlab_token(&state, &unverified_jwt, &key).await, _ => Err(unsupported_issuer(&unverified_issuer)), } } @@ -221,3 +227,167 @@ async fn handle_github_token_inner( let token = new_token.finalize().expose_secret().into(); Ok(Json(json::ExchangeResponse { token })) } + +async fn handle_gitlab_token( + state: &AppState, + unverified_jwt: &str, + key: &DecodingKey, +) -> AppResult> { + let audience = &state.config.trustpub_audience; + let signed_claims = GitLabClaims::decode(unverified_jwt, audience, key).map_err(|err| { + warn!("Failed to decode JWT: {err}"); + bad_request("Failed to decode JWT") + })?; + + let mut conn = state.db_write().await?; + + conn.transaction(|conn| Box::pin(handle_gitlab_token_inner(conn, signed_claims))) + .await +} + +async fn handle_gitlab_token_inner( + conn: &mut AsyncPgConnection, + signed_claims: GitLabClaims, +) -> AppResult> { + insert_jti(conn, &signed_claims.jti, signed_claims.exp).await?; + + // GitLab project paths can contain subgroups, which should be treated as + // part of the namespace. We use `rsplit_once()` here to split after the + // last slash in the full project path. + // + // In other words: `foo/bar/baz` becomes `(foo/bar, baz)`. + let project_path = &signed_claims.project_path; + let Some((namespace, project)) = project_path.rsplit_once('/') else { + warn!("Unexpected project_path format in JWT: {project_path}"); + let message = "Unexpected `project_path` value"; + return Err(bad_request(message)); + }; + + let Some(workflow_filepath) = signed_claims.workflow_filepath() else { + let ci_config_ref_uri = &signed_claims.ci_config_ref_uri; + warn!("Unexpected `ci_config_ref_uri` format in JWT: {ci_config_ref_uri}"); + let message = "Unexpected `ci_config_ref_uri` value"; + return Err(bad_request(message)); + }; + + let mut repo_configs = trustpub_configs_gitlab::table + .select(GitLabConfig::as_select()) + .filter(lower(trustpub_configs_gitlab::namespace).eq(lower(&namespace))) + .filter(lower(trustpub_configs_gitlab::project).eq(lower(&project))) + .load(conn) + .await?; + + if repo_configs.is_empty() { + let message = + format!("No Trusted Publishing config found for repository `{project_path}`."); + return Err(bad_request(message)); + } + + // First, handle resurrection protection by lazily storing namespace_id and + // verifying it on subsequent exchanges, before checking workflow/environment. + let configs_to_update: Vec = repo_configs + .iter() + .filter(|config| config.namespace_id.is_none()) + .map(|config| config.id) + .collect(); + + if !configs_to_update.is_empty() { + diesel::update(trustpub_configs_gitlab::table) + .filter(trustpub_configs_gitlab::id.eq_any(&configs_to_update)) + .filter(trustpub_configs_gitlab::namespace_id.is_null()) + .set(trustpub_configs_gitlab::namespace_id.eq(&signed_claims.namespace_id)) + .execute(conn) + .await?; + } + + // Remove configs that have a stored namespace_id which doesn't match + let mismatched_namespace_ids: Vec = repo_configs + .extract_if(.., |config| { + config + .namespace_id + .as_ref() + .is_some_and(|stored| stored != &signed_claims.namespace_id) + }) + .filter_map(|config| config.namespace_id) + .collect(); + + if repo_configs.is_empty() { + let message = format!( + "The Trusted Publishing config for repository `{project_path}` does not match the namespace ID ({}) in the JWT. Expected namespace IDs: {}. Please recreate the Trusted Publishing config to update the namespace ID.", + signed_claims.namespace_id, + mismatched_namespace_ids.join(", ") + ); + return Err(bad_request(message)); + } + + // Filter by workflow filepath match + let mismatched_workflows: Vec = repo_configs + .extract_if(.., |config| config.workflow_filepath != workflow_filepath) + .map(|config| format!("`{}`", config.workflow_filepath)) + .collect(); + + if repo_configs.is_empty() { + let message = format!( + "The Trusted Publishing config for repository `{project_path}` does not match the workflow filepath `{workflow_filepath}` in the JWT. Expected workflow filepaths: {}", + mismatched_workflows.join(", ") + ); + return Err(bad_request(message)); + } + + // Filter by environment (if config requires one) + 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 (case-insensitive) + (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 `{project_path}` does not match the environment `{signed_environment}` in the JWT. Expected environments: {}", + mismatched_environments.join(", ") + ) + } else { + format!( + "The Trusted Publishing config for repository `{project_path}` 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::GitLab { + project_path: signed_claims.project_path, + job_id: signed_claims.job_id, + sha: signed_claims.sha, + }; + + let new_token_model = NewToken { + expires_at: chrono::Utc::now() + chrono::Duration::minutes(30), + hashed_token: &new_token.sha256(), + crate_ids: &crate_ids, + trustpub_data: Some(&trustpub_data), + }; + + new_token_model.insert(conn).await?; + + let token = new_token.finalize().expose_secret().into(); + Ok(Json(json::ExchangeResponse { token })) +}