From fea3aa101f49865a2e4508fb47988b44be29525e Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Tue, 15 Apr 2025 12:33:59 +0200 Subject: [PATCH 1/7] Create "Trusted Publishing" database tables --- crates/crates_io_database/src/schema.patch | 26 ++++---- crates/crates_io_database/src/schema.rs | 56 +++++++++++++++++ .../crates_io_database_dump/src/dump-db.toml | 25 ++++++++ .../down.sql | 3 + .../up.sql | 61 +++++++++++++++++++ 5 files changed, 158 insertions(+), 13 deletions(-) create mode 100644 migrations/2025-04-25-090000_trusted-publishing/down.sql create mode 100644 migrations/2025-04-25-090000_trusted-publishing/up.sql diff --git a/crates/crates_io_database/src/schema.patch b/crates/crates_io_database/src/schema.patch index b7b8e0a71c1..18ce21eb9b8 100644 --- a/crates/crates_io_database/src/schema.patch +++ b/crates/crates_io_database/src/schema.patch @@ -1,6 +1,6 @@ --- original +++ patched -@@ -21,9 +21,7 @@ +@@ -14,9 +14,7 @@ /// The `pg_catalog.tsvector` SQL type /// /// (Automatically generated by Diesel.) @@ -9,9 +9,9 @@ - pub struct Tsvector; + pub use diesel_full_text_search::Tsvector; } - + diesel::table! { -@@ -74,9 +72,9 @@ +@@ -67,9 +65,9 @@ /// (Automatically generated by Diesel.) revoked -> Bool, /// NULL or an array of crate scope patterns (see RFC #2947) @@ -23,7 +23,7 @@ /// The `expired_at` column of the `api_tokens` table. /// /// Its SQL type is `Nullable`. -@@ -180,12 +178,6 @@ +@@ -175,12 +173,6 @@ /// /// (Automatically generated by Diesel.) created_at -> Timestamptz, @@ -35,8 +35,8 @@ - path -> Ltree, } } - -@@ -476,7 +468,7 @@ + +@@ -483,7 +475,7 @@ /// Its SQL type is `Array>`. /// /// (Automatically generated by Diesel.) @@ -45,9 +45,9 @@ /// The `target` column of the `dependencies` table. /// /// Its SQL type is `Nullable`. -@@ -703,6 +695,24 @@ +@@ -710,6 +702,24 @@ } - + diesel::table! { + /// Representation of the `recent_crate_downloads` view. + /// @@ -70,7 +70,7 @@ /// Representation of the `reserved_crate_names` table. /// /// (Automatically generated by Diesel.) -@@ -1018,7 +1028,8 @@ +@@ -1094,7 +1104,8 @@ diesel::joinable!(crate_downloads -> crates (crate_id)); diesel::joinable!(crate_owner_invitations -> crates (crate_id)); diesel::joinable!(crate_owners -> crates (crate_id)); @@ -80,19 +80,19 @@ diesel::joinable!(crates_categories -> categories (category_id)); diesel::joinable!(crates_categories -> crates (crate_id)); diesel::joinable!(crates_keywords -> crates (crate_id)); -@@ -1031,6 +1042,7 @@ +@@ -1110,6 +1121,7 @@ diesel::joinable!(publish_limit_buckets -> users (user_id)); diesel::joinable!(publish_rate_overrides -> users (user_id)); diesel::joinable!(readme_renderings -> versions (version_id)); +diesel::joinable!(recent_crate_downloads -> crates (crate_id)); + diesel::joinable!(trustpub_configs_github -> crates (crate_id)); diesel::joinable!(version_downloads -> versions (version_id)); diesel::joinable!(version_owner_actions -> api_tokens (api_token_id)); - diesel::joinable!(version_owner_actions -> users (user_id)); -@@ -1058,6 +1070,7 @@ +@@ -1140,6 +1152,7 @@ publish_limit_buckets, publish_rate_overrides, readme_renderings, + recent_crate_downloads, reserved_crate_names, teams, - users, + trustpub_configs_github, diff --git a/crates/crates_io_database/src/schema.rs b/crates/crates_io_database/src/schema.rs index 9b05b2434e1..9d43048b2e1 100644 --- a/crates/crates_io_database/src/schema.rs +++ b/crates/crates_io_database/src/schema.rs @@ -765,6 +765,58 @@ diesel::table! { } } +diesel::table! { + /// Trusted Publisher configuration for GitHub Actions + trustpub_configs_github (id) { + /// Unique identifier of the `trustpub_configs_github` row + id -> Int4, + /// Date and time when the configuration was created + created_at -> Timestamptz, + /// Unique identifier of the crate that this configuration is for + crate_id -> Int4, + /// GitHub name of the user or organization that owns the repository + repository_owner -> Varchar, + /// GitHub ID of the user or organization that owns the repository + repository_owner_id -> Int4, + /// Name of the repository that this configuration is for + repository_name -> Varchar, + /// Name of the workflow file inside the repository that will be used to publish the crate + workflow_filename -> Varchar, + /// GitHub Actions environment that will be used to publish the crate (if `NULL` the environment is unrestricted) + environment -> Nullable, + } +} + +diesel::table! { + /// Temporary access tokens for Trusted Publishing + trustpub_tokens (id) { + /// Unique identifier of the `trustpub_tokens` row + id -> Int8, + /// Date and time when the token was created + created_at -> Timestamptz, + /// Date and time when the token will expire + expires_at -> Timestamptz, + /// SHA256 hash of the token that can be used to publish the crate + hashed_token -> Bytea, + /// Unique identifiers of the crates that can be published using this token + crate_ids -> Array>, + } +} + +diesel::table! { + /// Used JWT IDs to prevent token reuse in the Trusted Publishing flow + trustpub_used_jtis (id) { + /// Unique identifier of the `trustpub_used_jtis` row + id -> Int8, + /// JWT ID from the OIDC token + jti -> Varchar, + /// Date and time when the JWT was used + used_at -> Timestamptz, + /// Date and time when the JWT would expire + expires_at -> Timestamptz, + } +} + diesel::table! { /// Representation of the `users` table. /// @@ -1070,6 +1122,7 @@ diesel::joinable!(publish_limit_buckets -> users (user_id)); diesel::joinable!(publish_rate_overrides -> users (user_id)); diesel::joinable!(readme_renderings -> versions (version_id)); diesel::joinable!(recent_crate_downloads -> crates (crate_id)); +diesel::joinable!(trustpub_configs_github -> crates (crate_id)); diesel::joinable!(version_downloads -> versions (version_id)); diesel::joinable!(version_owner_actions -> api_tokens (api_token_id)); diesel::joinable!(version_owner_actions -> users (user_id)); @@ -1102,6 +1155,9 @@ diesel::allow_tables_to_appear_in_same_query!( recent_crate_downloads, reserved_crate_names, teams, + trustpub_configs_github, + trustpub_tokens, + trustpub_used_jtis, users, version_downloads, version_owner_actions, diff --git a/crates/crates_io_database_dump/src/dump-db.toml b/crates/crates_io_database_dump/src/dump-db.toml index e42d45c59dd..c3c28ca558e 100644 --- a/crates/crates_io_database_dump/src/dump-db.toml +++ b/crates/crates_io_database_dump/src/dump-db.toml @@ -188,6 +188,31 @@ name = "public" avatar = "public" org_id = "public" +[trustpub_configs_github] +dependencies = ["crates"] +[trustpub_configs_github.columns] +id = "private" +created_at = "private" +crate_id = "private" +repository_owner = "private" +repository_owner_id = "private" +repository_name = "private" +workflow_filename = "private" +environment = "private" + +[trustpub_tokens.columns] +id = "private" +created_at = "private" +expires_at = "private" +hashed_token = "private" +crate_ids = "private" + +[trustpub_used_jtis.columns] +id = "private" +jti = "private" +used_at = "private" +expires_at = "private" + [users] filter = """ id in ( diff --git a/migrations/2025-04-25-090000_trusted-publishing/down.sql b/migrations/2025-04-25-090000_trusted-publishing/down.sql new file mode 100644 index 00000000000..382da1577b0 --- /dev/null +++ b/migrations/2025-04-25-090000_trusted-publishing/down.sql @@ -0,0 +1,3 @@ +drop table trustpub_configs_github; +drop table trustpub_tokens; +drop table trustpub_used_jtis; diff --git a/migrations/2025-04-25-090000_trusted-publishing/up.sql b/migrations/2025-04-25-090000_trusted-publishing/up.sql new file mode 100644 index 00000000000..8b112ff7dfc --- /dev/null +++ b/migrations/2025-04-25-090000_trusted-publishing/up.sql @@ -0,0 +1,61 @@ +create table trustpub_configs_github +( + id serial primary key, + created_at timestamptz not null default now(), + crate_id int not null references crates on delete cascade, + repository_owner varchar not null, + repository_owner_id int not null, + repository_name varchar not null, + workflow_filename varchar not null, + environment varchar +); + +comment on table trustpub_configs_github is 'Trusted Publisher configuration for GitHub Actions'; +comment on column trustpub_configs_github.id is 'Unique identifier of the `trustpub_configs_github` row'; +comment on column trustpub_configs_github.created_at is 'Date and time when the configuration was created'; +comment on column trustpub_configs_github.crate_id is 'Unique identifier of the crate that this configuration is for'; +comment on column trustpub_configs_github.repository_owner is 'GitHub name of the user or organization that owns the repository'; +comment on column trustpub_configs_github.repository_owner_id is 'GitHub ID of the user or organization that owns the repository'; +comment on column trustpub_configs_github.repository_name is 'Name of the repository that this configuration is for'; +comment on column trustpub_configs_github.workflow_filename is 'Name of the workflow file inside the repository that will be used to publish the crate'; +comment on column trustpub_configs_github.environment is 'GitHub Actions environment that will be used to publish the crate (if `NULL` the environment is unrestricted)'; + +------------------------------------------------------------------------------- + +create table trustpub_tokens +( + id bigserial primary key, + created_at timestamptz not null default now(), + expires_at timestamptz not null, + hashed_token bytea not null, + crate_ids int[] not null +); + +comment on table trustpub_tokens is 'Temporary access tokens for Trusted Publishing'; +comment on column trustpub_tokens.id is 'Unique identifier of the `trustpub_tokens` row'; +comment on column trustpub_tokens.created_at is 'Date and time when the token was created'; +comment on column trustpub_tokens.expires_at is 'Date and time when the token will expire'; +comment on column trustpub_tokens.hashed_token is 'SHA256 hash of the token that can be used to publish the crate'; +comment on column trustpub_tokens.crate_ids is 'Unique identifiers of the crates that can be published using this token'; + +create unique index trustpub_tokens_hashed_token_uindex + on trustpub_tokens (hashed_token); + +------------------------------------------------------------------------------- + +create table trustpub_used_jtis +( + id bigserial primary key, + jti varchar not null, + used_at timestamptz not null default now(), + expires_at timestamptz not null +); + +comment on table trustpub_used_jtis is 'Used JWT IDs to prevent token reuse in the Trusted Publishing flow'; +comment on column trustpub_used_jtis.id is 'Unique identifier of the `trustpub_used_jtis` row'; +comment on column trustpub_used_jtis.jti is 'JWT ID from the OIDC token'; +comment on column trustpub_used_jtis.used_at is 'Date and time when the JWT was used'; +comment on column trustpub_used_jtis.expires_at is 'Date and time when the JWT would expire'; + +create unique index trustpub_used_jtis_jti_uindex + on trustpub_used_jtis (jti); From 5efbf481d3004b9dcdc410edaebb58cb175a4bdb Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Mon, 28 Apr 2025 12:30:04 +0200 Subject: [PATCH 2/7] Trusted Publishing: Add `GitHubConfig` and `NewGitHubConfig` data access objects --- crates/crates_io_database/src/models/mod.rs | 1 + .../trusted_publishing/github_config.rs | 37 +++++++++++++++++++ .../src/models/trusted_publishing/mod.rs | 3 ++ 3 files changed, 41 insertions(+) create mode 100644 crates/crates_io_database/src/models/trusted_publishing/github_config.rs create mode 100644 crates/crates_io_database/src/models/trusted_publishing/mod.rs diff --git a/crates/crates_io_database/src/models/mod.rs b/crates/crates_io_database/src/models/mod.rs index 2b6f2698adc..35f561e09c2 100644 --- a/crates/crates_io_database/src/models/mod.rs +++ b/crates/crates_io_database/src/models/mod.rs @@ -33,5 +33,6 @@ pub mod krate; mod owner; pub mod team; pub mod token; +pub mod trusted_publishing; pub mod user; pub mod version; diff --git a/crates/crates_io_database/src/models/trusted_publishing/github_config.rs b/crates/crates_io_database/src/models/trusted_publishing/github_config.rs new file mode 100644 index 00000000000..c6291daca20 --- /dev/null +++ b/crates/crates_io_database/src/models/trusted_publishing/github_config.rs @@ -0,0 +1,37 @@ +use crate::schema::trustpub_configs_github; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; + +#[derive(Debug, Identifiable, Queryable, Selectable)] +#[diesel(table_name = trustpub_configs_github, check_for_backend(diesel::pg::Pg))] +pub struct GitHubConfig { + pub id: i32, + pub created_at: DateTime, + pub crate_id: i32, + pub repository_owner: String, + pub repository_owner_id: i32, + pub repository_name: String, + pub workflow_filename: String, + pub environment: Option, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = trustpub_configs_github, check_for_backend(diesel::pg::Pg))] +pub struct NewGitHubConfig<'a> { + pub crate_id: i32, + pub repository_owner: &'a str, + pub repository_owner_id: i32, + pub repository_name: &'a str, + pub workflow_filename: &'a str, + pub environment: Option<&'a str>, +} + +impl NewGitHubConfig<'_> { + pub async fn insert(&self, conn: &mut AsyncPgConnection) -> QueryResult { + self.insert_into(trustpub_configs_github::table) + .returning(GitHubConfig::as_returning()) + .get_result(conn) + .await + } +} diff --git a/crates/crates_io_database/src/models/trusted_publishing/mod.rs b/crates/crates_io_database/src/models/trusted_publishing/mod.rs new file mode 100644 index 00000000000..634f500f46d --- /dev/null +++ b/crates/crates_io_database/src/models/trusted_publishing/mod.rs @@ -0,0 +1,3 @@ +mod github_config; + +pub use self::github_config::{GitHubConfig, NewGitHubConfig}; From c4e77dafc20da9f4646bb651494299ba649892d5 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Mon, 28 Apr 2025 12:30:24 +0200 Subject: [PATCH 3/7] Trusted Publishing: Add `NewToken` data access object --- .../src/models/trusted_publishing/mod.rs | 2 ++ .../src/models/trusted_publishing/token.rs | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 crates/crates_io_database/src/models/trusted_publishing/token.rs diff --git a/crates/crates_io_database/src/models/trusted_publishing/mod.rs b/crates/crates_io_database/src/models/trusted_publishing/mod.rs index 634f500f46d..9d3e3e01453 100644 --- a/crates/crates_io_database/src/models/trusted_publishing/mod.rs +++ b/crates/crates_io_database/src/models/trusted_publishing/mod.rs @@ -1,3 +1,5 @@ mod github_config; +mod token; pub use self::github_config::{GitHubConfig, NewGitHubConfig}; +pub use self::token::NewToken; diff --git a/crates/crates_io_database/src/models/trusted_publishing/token.rs b/crates/crates_io_database/src/models/trusted_publishing/token.rs new file mode 100644 index 00000000000..80e6fcf5c84 --- /dev/null +++ b/crates/crates_io_database/src/models/trusted_publishing/token.rs @@ -0,0 +1,22 @@ +use crate::schema::trustpub_tokens; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; + +#[derive(Debug, Insertable)] +#[diesel(table_name = trustpub_tokens, check_for_backend(diesel::pg::Pg))] +pub struct NewToken<'a> { + pub expires_at: DateTime, + pub hashed_token: &'a [u8], + pub crate_ids: &'a [i32], +} + +impl NewToken<'_> { + pub async fn insert(&self, conn: &mut AsyncPgConnection) -> QueryResult<()> { + self.insert_into(trustpub_tokens::table) + .execute(conn) + .await?; + + Ok(()) + } +} From c92f7c6d6ec5f51d7fc981a6e68f70e87418720d Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Mon, 28 Apr 2025 12:30:58 +0200 Subject: [PATCH 4/7] Trusted Publishing: Add `NewUsedJti` data access object --- .../src/models/trusted_publishing/mod.rs | 2 ++ .../src/models/trusted_publishing/used_jti.rs | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 crates/crates_io_database/src/models/trusted_publishing/used_jti.rs diff --git a/crates/crates_io_database/src/models/trusted_publishing/mod.rs b/crates/crates_io_database/src/models/trusted_publishing/mod.rs index 9d3e3e01453..6a2ad6357b4 100644 --- a/crates/crates_io_database/src/models/trusted_publishing/mod.rs +++ b/crates/crates_io_database/src/models/trusted_publishing/mod.rs @@ -1,5 +1,7 @@ mod github_config; mod token; +mod used_jti; pub use self::github_config::{GitHubConfig, NewGitHubConfig}; pub use self::token::NewToken; +pub use self::used_jti::NewUsedJti; diff --git a/crates/crates_io_database/src/models/trusted_publishing/used_jti.rs b/crates/crates_io_database/src/models/trusted_publishing/used_jti.rs new file mode 100644 index 00000000000..eced690bf0b --- /dev/null +++ b/crates/crates_io_database/src/models/trusted_publishing/used_jti.rs @@ -0,0 +1,24 @@ +use crate::schema::trustpub_used_jtis; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; + +#[derive(Debug, Insertable)] +#[diesel(table_name = trustpub_used_jtis, check_for_backend(diesel::pg::Pg))] +pub struct NewUsedJti<'a> { + pub jti: &'a str, + pub expires_at: DateTime, +} + +impl<'a> NewUsedJti<'a> { + pub fn new(jti: &'a str, expires_at: DateTime) -> Self { + Self { jti, expires_at } + } + + pub async fn insert(&self, conn: &mut AsyncPgConnection) -> QueryResult { + diesel::insert_into(trustpub_used_jtis::table) + .values(self) + .execute(conn) + .await + } +} From 6ff62f4c2545a8ad31554f08912b153afab0c5a7 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 16 Apr 2025 12:16:55 +0200 Subject: [PATCH 5/7] tests/util: Change `MockTokenUser::token` to be optional --- src/tests/util.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/tests/util.rs b/src/tests/util.rs index 0136cd2478e..d7a25a96eb5 100644 --- a/src/tests/util.rs +++ b/src/tests/util.rs @@ -335,7 +335,7 @@ impl MockCookieUser { MockTokenUser { app: self.app.clone(), - token, + token: Some(token), plaintext: plaintext.expose_secret().into(), } } @@ -344,7 +344,7 @@ impl MockCookieUser { /// A type that can generate token authenticated requests pub struct MockTokenUser { app: TestApp, - token: ApiToken, + token: Option, plaintext: String, } @@ -363,7 +363,8 @@ impl RequestHelper for MockTokenUser { impl MockTokenUser { /// Returns a reference to the database `ApiToken` model pub fn as_model(&self) -> &ApiToken { - &self.token + const ERROR: &str = "Original `ApiToken` was not set on this `MockTokenUser` instance"; + self.token.as_ref().expect(ERROR) } pub fn plaintext(&self) -> &str { From 7b990e558b38cae2a34336082ef0bce8dad6d276 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 16 Apr 2025 12:27:47 +0200 Subject: [PATCH 6/7] tests/util: Add `MockTokenUser::for_token()` fn This makes it possible to construct `MockTokenUser` instances from an existing plaintext token. --- src/tests/util.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/tests/util.rs b/src/tests/util.rs index d7a25a96eb5..076eb242e88 100644 --- a/src/tests/util.rs +++ b/src/tests/util.rs @@ -39,6 +39,7 @@ use http::header; use secrecy::ExposeSecret; use serde_json::json; use std::collections::HashMap; +use std::fmt::Display; use std::net::SocketAddr; use tower::ServiceExt; @@ -361,6 +362,14 @@ impl RequestHelper for MockTokenUser { } impl MockTokenUser { + pub fn for_token(token: impl Display, app: TestApp) -> Self { + Self { + app, + token: None, + plaintext: format!("Bearer {token}"), + } + } + /// Returns a reference to the database `ApiToken` model pub fn as_model(&self) -> &ApiToken { const ERROR: &str = "Original `ApiToken` was not set on this `MockTokenUser` instance"; From 607a12afa99b86687eb447813469a67818ead867 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 16 Apr 2025 09:36:53 +0200 Subject: [PATCH 7/7] WIP --- Cargo.lock | 92 + Cargo.toml | 3 + .../crates_io_database/src/models/version.rs | 20 +- .../crates_io_trusted_publishing/Cargo.toml | 28 + crates/crates_io_trusted_publishing/README.md | 1 + .../src/github/claims.rs | 390 ++ .../src/github/mod.rs | 6 + .../src/github/workflows.rs | 105 + .../src/keystore/impl.rs | 87 + .../src/keystore/load_jwks.rs | 144 + .../src/keystore/mod.rs | 38 + ...keystore__load_jwks__tests__load_jwks.snap | 126 + .../crates_io_trusted_publishing/src/lib.rs | 9 + .../src/test_keys.rs | 70 + .../src/unverified.rs | 34 + src/app.rs | 4 + src/bin/server.rs | 13 +- src/controllers.rs | 2 + src/controllers/github_oidc_configs.rs | 119 + src/controllers/krate/publish.rs | 154 +- src/controllers/oidc_tokens.rs | 198 + src/router.rs | 6 + ..._openapi__tests__openapi_snapshot.snap.new | 4364 +++++++++++++++++ src/tests/builders/version.rs | 4 +- src/tests/krate/publish/mod.rs | 1 + src/tests/krate/publish/oidc.rs | 128 + src/tests/routes/mod.rs | 1 + src/tests/routes/oidc_tokens/exchange.rs | 415 ++ src/tests/routes/oidc_tokens/mod.rs | 1 + src/tests/util.rs | 1 + src/tests/util/oidc.rs | 95 + src/tests/util/test_app.rs | 24 +- src/worker/jobs/downloads/update_metadata.rs | 2 +- 33 files changed, 6617 insertions(+), 68 deletions(-) create mode 100644 crates/crates_io_trusted_publishing/Cargo.toml create mode 100644 crates/crates_io_trusted_publishing/README.md create mode 100644 crates/crates_io_trusted_publishing/src/github/claims.rs create mode 100644 crates/crates_io_trusted_publishing/src/github/mod.rs create mode 100644 crates/crates_io_trusted_publishing/src/github/workflows.rs create mode 100644 crates/crates_io_trusted_publishing/src/keystore/impl.rs create mode 100644 crates/crates_io_trusted_publishing/src/keystore/load_jwks.rs create mode 100644 crates/crates_io_trusted_publishing/src/keystore/mod.rs create mode 100644 crates/crates_io_trusted_publishing/src/keystore/snapshots/crates_io_oidc__keystore__load_jwks__tests__load_jwks.snap create mode 100644 crates/crates_io_trusted_publishing/src/lib.rs create mode 100644 crates/crates_io_trusted_publishing/src/test_keys.rs create mode 100644 crates/crates_io_trusted_publishing/src/unverified.rs create mode 100644 src/controllers/github_oidc_configs.rs create mode 100644 src/controllers/oidc_tokens.rs create mode 100644 src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap.new create mode 100644 src/tests/krate/publish/oidc.rs create mode 100644 src/tests/routes/oidc_tokens/exchange.rs create mode 100644 src/tests/routes/oidc_tokens/mod.rs create mode 100644 src/tests/util/oidc.rs diff --git a/Cargo.lock b/Cargo.lock index 327be5a18ef..36d86c381d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,6 +193,16 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "astral-tokio-tar" version = "0.5.2" @@ -1198,6 +1208,7 @@ dependencies = [ "crates_io_tarball", "crates_io_team_repo", "crates_io_test_db", + "crates_io_trusted_publishing", "crates_io_worker", "csv", "deadpool-diesel", @@ -1219,6 +1230,7 @@ dependencies = [ "insta", "ipnetwork", "json-subscriber", + "jsonwebtoken", "lettre", "minijinja", "mockall", @@ -1493,6 +1505,24 @@ dependencies = [ "url", ] +[[package]] +name = "crates_io_trusted_publishing" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "insta", + "jsonwebtoken", + "mockall", + "mockito", + "regex", + "reqwest", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "crates_io_worker" version = "0.0.0" @@ -3140,6 +3170,21 @@ dependencies = [ "uuid", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -3478,6 +3523,30 @@ dependencies = [ "syn", ] +[[package]] +name = "mockito" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7760e0e418d9b7e5777c0374009ca4c93861b9066f18cb334a20ce50ab63aa48" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.6.0", + "hyper-util", + "log", + "rand 0.9.1", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + [[package]] name = "moka" version = "0.12.10" @@ -3803,6 +3872,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +dependencies = [ + "base64 0.22.1", + "serde", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -5044,6 +5123,18 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.12", + "time", +] + [[package]] name = "siphasher" version = "1.0.1" @@ -5426,6 +5517,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", diff --git a/Cargo.toml b/Cargo.toml index 36aefa033fe..3d0b6328a64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,7 @@ crates_io_env_vars = { path = "crates/crates_io_env_vars" } crates_io_github = { path = "crates/crates_io_github" } crates_io_index = { path = "crates/crates_io_index" } crates_io_markdown = { path = "crates/crates_io_markdown" } +crates_io_trusted_publishing = { path = "crates/crates_io_trusted_publishing" } crates_io_pagerduty = { path = "crates/crates_io_pagerduty" } crates_io_session = { path = "crates/crates_io_session" } crates_io_tarball = { path = "crates/crates_io_tarball" } @@ -139,6 +140,7 @@ utoipa-axum = "=0.2.0" bytes = "=1.10.1" crates_io_github = { path = "crates/crates_io_github", features = ["mock"] } crates_io_index = { path = "crates/crates_io_index", features = ["testing"] } +crates_io_trusted_publishing = { path = "crates/crates_io_trusted_publishing", features = ["mock"] } crates_io_tarball = { path = "crates/crates_io_tarball", features = ["builder"] } crates_io_team_repo = { path = "crates/crates_io_team_repo", features = ["mock"] } crates_io_test_db = { path = "crates/crates_io_test_db" } @@ -146,6 +148,7 @@ claims = "=0.8.0" diesel = { version = "=2.2.10", features = ["r2d2"] } googletest = "=0.14.0" insta = { version = "=1.43.0", features = ["glob", "json", "redactions"] } +jsonwebtoken = "=9.3.1" regex = "=1.11.1" sentry = { version = "=0.37.0", features = ["test"] } tokio = "=1.44.2" diff --git a/crates/crates_io_database/src/models/version.rs b/crates/crates_io_database/src/models/version.rs index 1fc2e1eef5d..65dc11d5e9f 100644 --- a/crates/crates_io_database/src/models/version.rs +++ b/crates/crates_io_database/src/models/version.rs @@ -91,7 +91,7 @@ pub struct NewVersion<'a> { license: Option<&'a str>, #[builder(default, name = "size")] crate_size: i32, - published_by: i32, + published_by: Option, checksum: &'a str, links: Option<&'a str>, rust_version: Option<&'a str>, @@ -110,7 +110,7 @@ impl NewVersion<'_> { pub async fn save( &self, conn: &mut AsyncPgConnection, - published_by_email: &str, + published_by_email: Option<&str>, ) -> QueryResult { use diesel::insert_into; @@ -122,13 +122,15 @@ impl NewVersion<'_> { .get_result(conn) .await?; - insert_into(versions_published_by::table) - .values(( - versions_published_by::version_id.eq(version.id), - versions_published_by::email.eq(published_by_email), - )) - .execute(conn) - .await?; + if let Some(published_by_email) = published_by_email { + insert_into(versions_published_by::table) + .values(( + versions_published_by::version_id.eq(version.id), + versions_published_by::email.eq(published_by_email), + )) + .execute(conn) + .await?; + } Ok(version) } diff --git a/crates/crates_io_trusted_publishing/Cargo.toml b/crates/crates_io_trusted_publishing/Cargo.toml new file mode 100644 index 00000000000..64dd88bf8e4 --- /dev/null +++ b/crates/crates_io_trusted_publishing/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "crates_io_trusted_publishing" +version = "0.0.0" +license = "MIT OR Apache-2.0" +edition = "2024" + +[lints] +workspace = true + +[features] +mock = ["dep:mockall", "dep:serde_json"] + +[dependencies] +anyhow = "=1.0.98" +async-trait = "=0.1.88" +chrono = { version = "=0.4.40", features = ["serde"] } +jsonwebtoken = "=9.3.1" +mockall = { version = "=0.13.1", optional = true } +reqwest = { version = "=0.12.15", features = ["gzip", "json"] } +regex = "=1.11.1" +serde = { version = "=1.0.219", features = ["derive"] } +serde_json = { version = "=1.0.140", optional = true } +tokio = { version = "=1.44.2", features = ["sync"] } + +[dev-dependencies] +insta = { version = "=1.43.0", features = ["json", "redactions"] } +mockito = "=1.7.0" +tokio = { version = "=1.44.2", features = ["macros", "rt-multi-thread"] } diff --git a/crates/crates_io_trusted_publishing/README.md b/crates/crates_io_trusted_publishing/README.md new file mode 100644 index 00000000000..a3e5ff1258b --- /dev/null +++ b/crates/crates_io_trusted_publishing/README.md @@ -0,0 +1 @@ +# crates_io_trusted_publishing diff --git a/crates/crates_io_trusted_publishing/src/github/claims.rs b/crates/crates_io_trusted_publishing/src/github/claims.rs new file mode 100644 index 00000000000..18a2a86057d --- /dev/null +++ b/crates/crates_io_trusted_publishing/src/github/claims.rs @@ -0,0 +1,390 @@ +use crate::EXPECTED_AUDIENCE; +use crate::github::GITHUB_ISSUER_URL; +use crate::github::workflows::extract_workflow_filename; +use chrono::serde::ts_seconds; +use chrono::{DateTime, Utc}; +use jsonwebtoken::errors::{Error, ErrorKind}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation}; +use std::sync::LazyLock; + +static GITHUB_VALIDATION: LazyLock = LazyLock::new(|| { + 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(&[GITHUB_ISSUER_URL]); + validation.set_audience(&[EXPECTED_AUDIENCE]); + validation +}); + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct GitHubClaims { + pub aud: String, + #[serde(with = "ts_seconds")] + pub iat: DateTime, + #[serde(with = "ts_seconds")] + pub exp: DateTime, + pub jti: String, + + pub repository_owner_id: String, + pub repository: String, + pub job_workflow_ref: String, + pub environment: Option, +} + +impl GitHubClaims { + /// Decode and validate a JWT token, returning the relevant claims if valid. + pub fn decode(token: &str, key: &DecodingKey) -> Result { + let claims: Self = jsonwebtoken::decode(token, key, &GITHUB_VALIDATION)?.claims; + + let leeway = chrono::TimeDelta::seconds(GITHUB_VALIDATION.leeway as i64); + if claims.iat > Utc::now() + leeway { + return Err(ErrorKind::ImmatureSignature.into()); + } + + Ok(claims) + } + + /// Extract the workflow filename from the `job_workflow_ref` field, + /// or return `None` if the filename cannot be extracted. + pub fn workflow_filename(&self) -> Option<&str> { + extract_workflow_filename(&self.job_workflow_ref) + } +} + +#[cfg(all(test, feature = "mock"))] +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; + + #[test] + fn test_decode() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "sub": "repo:octo-org/octo-repo:environment:prod", + "environment": "prod", + "aud": EXPECTED_AUDIENCE, + "ref": "refs/heads/main", + "sha": "example-sha", + "repository": "octo-org/octo-repo", + "repository_owner": "octo-org", + "actor_id": "12", + "repository_visibility": "private", + "repository_id": "74", + "repository_owner_id": "65", + "run_id": "example-run-id", + "run_number": "10", + "run_attempt": "2", + "runner_environment": "github-hosted", + "actor": "octocat", + "workflow": "example-workflow", + "head_ref": "", + "base_ref": "", + "event_name": "workflow_dispatch", + "ref_type": "branch", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "nbf": now, + "exp": now + 30, + "iat": now, + }))?; + + let claims = GitHubClaims::decode(&jwt, &DECODING_KEY)?; + assert_json_snapshot!(claims, { ".iat" => "[datetime]", ".exp" => "[datetime]" }, @r#" + { + "aud": "crates.io", + "iat": "[datetime]", + "exp": "[datetime]", + "jti": "example-id", + "repository_owner_id": "65", + "repository": "octo-org/octo-repo", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "environment": "prod" + } + "#); + + Ok(()) + } + + #[test] + fn test_decode_minimal() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "aud": EXPECTED_AUDIENCE, + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "exp": now + 30, + "iat": now, + }))?; + + let claims = GitHubClaims::decode(&jwt, &DECODING_KEY)?; + assert_json_snapshot!(claims, { ".iat" => "[datetime]", ".exp" => "[datetime]" }, @r#" + { + "aud": "crates.io", + "iat": "[datetime]", + "exp": "[datetime]", + "jti": "example-id", + "repository_owner_id": "65", + "repository": "octo-org/octo-repo", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "environment": null + } + "#); + + Ok(()) + } + + #[test] + fn test_decode_missing_jti() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "aud": EXPECTED_AUDIENCE, + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "exp": now + 30, + "iat": now, + }))?; + + let error = GitHubClaims::decode(&jwt, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `jti`", line: 1, column: 255)))"#); + + Ok(()) + } + + #[test] + fn test_decode_wrong_audience() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "aud": "somebody-else", + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "exp": now + 30, + "iat": now, + }))?; + + let error = GitHubClaims::decode(&jwt, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r"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!({ + "jti": "example-id", + "aud": [EXPECTED_AUDIENCE, "somebody-else"], + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "exp": now + 30, + "iat": now, + }))?; + + let error = GitHubClaims::decode(&jwt, &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_repo() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "aud": EXPECTED_AUDIENCE, + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "exp": now + 30, + "iat": now, + }))?; + + let error = GitHubClaims::decode(&jwt, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `repository`", line: 1, column: 240)))"#); + + Ok(()) + } + #[test] + fn test_decode_missing_owner_id() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "aud": EXPECTED_AUDIENCE, + "repository": "octo-org/octo-repo", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "exp": now + 30, + "iat": now, + }))?; + + let error = GitHubClaims::decode(&jwt, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `repository_owner_id`", line: 1, column: 247)))"#); + + Ok(()) + } + #[test] + fn test_decode_missing_workflow() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "aud": EXPECTED_AUDIENCE, + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "iss": "https://token.actions.githubusercontent.com", + "exp": now + 30, + "iat": now, + }))?; + + let error = GitHubClaims::decode(&jwt, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `job_workflow_ref`", line: 1, column: 185)))"#); + + Ok(()) + } + + #[test] + fn test_decode_missing_issuer() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "aud": EXPECTED_AUDIENCE, + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "exp": now + 30, + "iat": now, + }))?; + + let error = GitHubClaims::decode(&jwt, &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!({ + "jti": "example-id", + "aud": EXPECTED_AUDIENCE, + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://gitlab.com", + "exp": now + 30, + "iat": now, + }))?; + + let error = GitHubClaims::decode(&jwt, &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!({ + "jti": "example-id", + "aud": EXPECTED_AUDIENCE, + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "iat": now, + }))?; + + let error = GitHubClaims::decode(&jwt, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `exp`", line: 1, column: 257)))"#); + + Ok(()) + } + + #[test] + fn test_decode_expired() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "aud": EXPECTED_AUDIENCE, + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "exp": now - 3000, + "iat": now - 6000, + }))?; + + let error = GitHubClaims::decode(&jwt, &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!({ + "jti": "example-id", + "aud": EXPECTED_AUDIENCE, + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "exp": now + 30, + }))?; + + let error = GitHubClaims::decode(&jwt, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `iat`", line: 1, column: 257)))"#); + + Ok(()) + } + + #[test] + fn test_decode_future_iat() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "aud": EXPECTED_AUDIENCE, + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "exp": now + 300, + "iat": now + 100, + }))?; + + let error = GitHubClaims::decode(&jwt, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @"Error(ImmatureSignature)"); + + Ok(()) + } +} diff --git a/crates/crates_io_trusted_publishing/src/github/mod.rs b/crates/crates_io_trusted_publishing/src/github/mod.rs new file mode 100644 index 00000000000..0a4457d12ad --- /dev/null +++ b/crates/crates_io_trusted_publishing/src/github/mod.rs @@ -0,0 +1,6 @@ +mod claims; +mod workflows; + +pub use claims::GitHubClaims; + +pub const GITHUB_ISSUER_URL: &str = "https://token.actions.githubusercontent.com"; diff --git a/crates/crates_io_trusted_publishing/src/github/workflows.rs b/crates/crates_io_trusted_publishing/src/github/workflows.rs new file mode 100644 index 00000000000..d7286b2b658 --- /dev/null +++ b/crates/crates_io_trusted_publishing/src/github/workflows.rs @@ -0,0 +1,105 @@ +use std::sync::LazyLock; + +pub(crate) fn extract_workflow_filename(workflow_ref: &str) -> Option<&str> { + static WORKFLOW_REF_RE: LazyLock = + LazyLock::new(|| regex::Regex::new(r"([^/]+\.(yml|yaml))(@.+)").unwrap()); + + WORKFLOW_REF_RE + .captures(workflow_ref) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str()) +} + +#[cfg(test)] +mod tests { + #[test] + fn test_extract_workflow_filename() { + let test_cases = [ + // Well-formed workflow refs, including exceedingly obnoxious ones + // with `@` or extra suffixes or `git` refs that look like workflows. + ( + "foo/bar/.github/workflows/basic.yml@refs/heads/main", + Some("basic.yml"), + ), + ( + "foo/bar/.github/workflows/basic.yaml@refs/heads/main", + Some("basic.yaml"), + ), + ( + "foo/bar/.github/workflows/has-dash.yml@refs/heads/main", + Some("has-dash.yml"), + ), + ( + "foo/bar/.github/workflows/has--dashes.yml@refs/heads/main", + Some("has--dashes.yml"), + ), + ( + "foo/bar/.github/workflows/has--dashes-.yml@refs/heads/main", + Some("has--dashes-.yml"), + ), + ( + "foo/bar/.github/workflows/has.period.yml@refs/heads/main", + Some("has.period.yml"), + ), + ( + "foo/bar/.github/workflows/has..periods.yml@refs/heads/main", + Some("has..periods.yml"), + ), + ( + "foo/bar/.github/workflows/has..periods..yml@refs/heads/main", + Some("has..periods..yml"), + ), + ( + "foo/bar/.github/workflows/has_underscore.yml@refs/heads/main", + Some("has_underscore.yml"), + ), + ( + "foo/bar/.github/workflows/nested@evil.yml@refs/heads/main", + Some("nested@evil.yml"), + ), + ( + "foo/bar/.github/workflows/nested.yml@evil.yml@refs/heads/main", + Some("nested.yml@evil.yml"), + ), + ( + "foo/bar/.github/workflows/extra@nested.yml@evil.yml@refs/heads/main", + Some("extra@nested.yml@evil.yml"), + ), + ( + "foo/bar/.github/workflows/extra.yml@nested.yml@evil.yml@refs/heads/main", + Some("extra.yml@nested.yml@evil.yml"), + ), + ( + "foo/bar/.github/workflows/basic.yml@refs/heads/misleading@branch.yml", + Some("basic.yml"), + ), + ( + "foo/bar/.github/workflows/basic.yml@refs/heads/bad@branch@twomatches.yml", + Some("basic.yml"), + ), + ( + "foo/bar/.github/workflows/foo.yml.yml@refs/heads/main", + Some("foo.yml.yml"), + ), + ( + "foo/bar/.github/workflows/foo.yml.foo.yml@refs/heads/main", + Some("foo.yml.foo.yml"), + ), + // Malformed workflow refs. + ( + "foo/bar/.github/workflows/basic.wrongsuffix@refs/heads/main", + None, + ), + ("foo/bar/.github/workflows/@refs/heads/main", None), + ("foo/bar/.github/workflows/nosuffix@refs/heads/main", None), + ("foo/bar/.github/workflows/.yml@refs/heads/main", None), + ("foo/bar/.github/workflows/.yaml@refs/heads/main", None), + ("foo/bar/.github/workflows/main.yml", None), + ]; + + for (input, expected) in test_cases { + let result = super::extract_workflow_filename(input); + assert_eq!(result, expected, "Input: {input}"); + } + } +} diff --git a/crates/crates_io_trusted_publishing/src/keystore/impl.rs b/crates/crates_io_trusted_publishing/src/keystore/impl.rs new file mode 100644 index 00000000000..5de30a80df6 --- /dev/null +++ b/crates/crates_io_trusted_publishing/src/keystore/impl.rs @@ -0,0 +1,87 @@ +use super::OidcKeyStore; +use super::load_jwks::load_jwks; +use crate::github::GITHUB_ISSUER_URL; +use async_trait::async_trait; +use jsonwebtoken::DecodingKey; +use std::collections::HashMap; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +/// The main implementation of the [`OidcKeyStore`] trait. +/// +/// This struct fetches OIDC keys from a remote provider and caches them. If +/// a key is not found in the cache, it will attempt to refresh the cached +/// key set, unless the cache has just recently been refreshed already. +pub struct RealOidcKeyStore { + issuer_uri: String, + client: reqwest::Client, + cache: RwLock, +} + +#[derive(Default)] +struct Cache { + keys: HashMap, + last_update: Option, +} + +impl RealOidcKeyStore { + /// Creates a new instance of [`RealOidcKeyStore`]. + pub fn new(issuer_uri: String) -> Self { + let client = reqwest::Client::builder() + .user_agent("crates.io") + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + + Self { + issuer_uri, + client, + cache: RwLock::new(Cache::default()), + } + } + + pub fn github() -> Self { + Self::new(GITHUB_ISSUER_URL.into()) + } +} + +#[async_trait] +impl OidcKeyStore for RealOidcKeyStore { + async fn get_oidc_key(&self, key_id: &str) -> anyhow::Result> { + const MIN_AGE_BEFORE_REFRESH: Duration = Duration::from_secs(60); + + // First, try to get the key with just a read lock. + let cache = self.cache.read().await; + if let Some(key) = cache.keys.get(key_id) { + return Ok(Some(key.clone())); + } + + // If that fails, drop the read lock before acquiring the write lock. + drop(cache); + + let mut cache = self.cache.write().await; + if cache + .last_update + .is_some_and(|last_update| last_update.elapsed() < MIN_AGE_BEFORE_REFRESH) + { + // If we're in a cooldown from a previous refresh, return + // whatever is in the cache. + return Ok(cache.keys.get(key_id).cloned()); + } + + // Load the keys from the OIDC provider. + let jwks = load_jwks(&self.client, &self.issuer_uri).await?; + + cache.keys.clear(); + for key in jwks.keys { + if let Some(key_id) = &key.common.key_id { + let decoding_key = DecodingKey::from_jwk(&key)?; + cache.keys.insert(key_id.clone(), decoding_key); + } + } + + cache.last_update = Some(Instant::now()); + + Ok(cache.keys.get(key_id).cloned()) + } +} diff --git a/crates/crates_io_trusted_publishing/src/keystore/load_jwks.rs b/crates/crates_io_trusted_publishing/src/keystore/load_jwks.rs new file mode 100644 index 00000000000..957587feaa2 --- /dev/null +++ b/crates/crates_io_trusted_publishing/src/keystore/load_jwks.rs @@ -0,0 +1,144 @@ +use jsonwebtoken::jwk::JwkSet; +use reqwest::Client; + +pub async fn load_jwks(client: &Client, issuer_uri: &str) -> reqwest::Result { + #[derive(Debug, serde::Deserialize)] + struct OpenIdConfig { + jwks_uri: String, + } + + let url = format!("{issuer_uri}/.well-known/openid-configuration"); + let response = client.get(url).send().await?.error_for_status()?; + let openid_config: OpenIdConfig = response.json().await?; + + let url = openid_config.jwks_uri; + let response = client.get(url).send().await?.error_for_status()?; + let jwks: JwkSet = response.json().await?; + + Ok(jwks) +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_debug_snapshot; + + #[tokio::test] + async fn test_load_jwks() { + let mut server = mockito::Server::new_async().await; + + let issuer_url = server.url(); + + let config = format!( + r#"{{ + "issuer": "{issuer_url}", + "jwks_uri": "{issuer_url}/.well-known/jwks", + "subject_types_supported": [ + "public", + "pairwise" + ], + "response_types_supported": [ + "id_token" + ], + "claims_supported": [ + "sub", + "aud", + "exp", + "iat", + "iss", + "jti", + "nbf", + "ref", + "sha", + "repository", + "repository_id", + "repository_owner", + "repository_owner_id", + "enterprise", + "enterprise_id", + "run_id", + "run_number", + "run_attempt", + "actor", + "actor_id", + "workflow", + "workflow_ref", + "workflow_sha", + "head_ref", + "base_ref", + "event_name", + "ref_type", + "ref_protected", + "environment", + "environment_node_id", + "job_workflow_ref", + "job_workflow_sha", + "repository_visibility", + "runner_environment", + "issuer_scope" + ], + "id_token_signing_alg_values_supported": [ + "RS256" + ], + "scopes_supported": [ + "openid" + ] + }}"#, + ); + + let jwks = r#"{ + "keys": [{ + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": "cc413527-173f-5a05-976e-9c52b1d7b431", + "n": "w4M936N3ZxNaEblcUoBm-xu0-V9JxNx5S7TmF0M3SBK-2bmDyAeDdeIOTcIVZHG-ZX9N9W0u1yWafgWewHrsz66BkxXq3bscvQUTAw7W3s6TEeYY7o9shPkFfOiU3x_KYgOo06SpiFdymwJflRs9cnbaU88i5fZJmUepUHVllP2tpPWTi-7UA3AdP3cdcCs5bnFfTRKzH2W0xqKsY_jIG95aQJRBDpbiesefjuyxcQnOv88j9tCKWzHpJzRKYjAUM6OPgN4HYnaSWrPJj1v41eEkFM1kORuj-GSH2qMVD02VklcqaerhQHIqM-RjeHsN7G05YtwYzomE5G-fZuwgvQ", + "e": "AQAB" + }, { + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": "38826b17-6a30-5f9b-b169-8beb8202f723", + "n": "5Manmy-zwsk3wEftXNdKFZec4rSWENW4jTGevlvAcU9z3bgLBogQVvqYLtu9baVm2B3rfe5onadobq8po5UakJ0YsTiiEfXWdST7YI2Sdkvv-hOYMcZKYZ4dFvuSO1vQ2DgEkw_OZNiYI1S518MWEcNxnPU5u67zkawAGsLlmXNbOylgVfBRJrG8gj6scr-sBs4LaCa3kg5IuaCHe1pB-nSYHovGV_z0egE83C098FfwO1dNZBWeo4Obhb5Z-ZYFLJcZfngMY0zJnCVNmpHQWOgxfGikh3cwi4MYrFrbB4NTlxbrQ3bL-rGKR5X318veyDlo8Dyz2KWMobT4wB9U1Q", + "e": "AQAB", + "x5c": ["MIIDKzCCAhOgAwIBAgIUDnwm6eRIqGFA3o/P1oBrChvx/nowDQYJKoZIhvcNAQELBQAwJTEjMCEGA1UEAwwaYWN0aW9ucy5zZWxmLXNpZ25lZC5naXRodWIwHhcNMjQwMTIzMTUyNTM2WhcNMzQwMTIwMTUyNTM2WjAlMSMwIQYDVQQDDBphY3Rpb25zLnNlbGYtc2lnbmVkLmdpdGh1YjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOTGp5svs8LJN8BH7VzXShWXnOK0lhDVuI0xnr5bwHFPc924CwaIEFb6mC7bvW2lZtgd633uaJ2naG6vKaOVGpCdGLE4ohH11nUk+2CNknZL7/oTmDHGSmGeHRb7kjtb0Ng4BJMPzmTYmCNUudfDFhHDcZz1Obuu85GsABrC5ZlzWzspYFXwUSaxvII+rHK/rAbOC2gmt5IOSLmgh3taQfp0mB6Lxlf89HoBPNwtPfBX8DtXTWQVnqODm4W+WfmWBSyXGX54DGNMyZwlTZqR0FjoMXxopId3MIuDGKxa2weDU5cW60N2y/qxikeV99fL3sg5aPA8s9iljKG0+MAfVNUCAwEAAaNTMFEwHQYDVR0OBBYEFIPALo5VanJ6E1B9eLQgGO+uGV65MB8GA1UdIwQYMBaAFIPALo5VanJ6E1B9eLQgGO+uGV65MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGS0hZE+DqKIRi49Z2KDOMOaSZnAYgqq6ws9HJHT09MXWlMHB8E/apvy2ZuFrcSu14ZLweJid+PrrooXEXEO6azEakzCjeUb9G1QwlzP4CkTcMGCw1Snh3jWZIuKaw21f7mp2rQ+YNltgHVDKY2s8AD273E8musEsWxJl80/MNvMie8Hfh4n4/Xl2r6t1YPmUJMoXAXdTBb0hkPy1fUu3r2T+1oi7Rw6kuVDfAZjaHupNHzJeDOg2KxUoK/GF2/M2qpVrd19Pv/JXNkQXRE4DFbErMmA7tXpp1tkXJRPhFui/Pv5H9cPgObEf9x6W4KnCXzT3ReeeRDKF8SqGTPELsc="], + "x5t": "ykNaY4qM_ta4k2TgZOCEYLkcYlA" + }, { + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": "1F2AB83404C08EC9EA0BB99DAED02186B091DBF4", + "n": "u8zSYn5JR_O5yywSeOhmWWd7OMoLblh4iGTeIhTOVon-5e54RK30YQDeUCjpb9u3vdHTO7XS7i6EzkwLbsUOir27uhqoFGGWXSAZrPocOobSFoLC5l0NvSKRqVtpoADOHcAh59vLbr8dz3xtEEGx_qlLTzfFfWiCIYWiy15C2oo1eNPxzQfOvdu7Yet6Of4musV0Es5_mNETpeHOVEri8PWfxzw485UHIj3socl4Lk_I3iDyHfgpT49tIJYhHE5NImLNdwMha1cBCIbJMy1dJCfdoK827Hi9qKyBmftNQPhezGVRsOjsf2BfUGzGP5pCGrFBjEOcLhj_3j-TJebgvQ", + "e": "AQAB", + "x5c": ["MIIDrDCCApSgAwIBAgIQAP4blP36Q3WmMOhWf0RBMzANBgkqhkiG9w0BAQsFADA2MTQwMgYDVQQDEyt2c3RzLXZzdHNnaHJ0LWdoLXZzby1vYXV0aC52aXN1YWxzdHVkaW8uY29tMB4XDTIzMTAyNDE0NTI1NVoXDTI1MTAyNDE1MDI1NVowNjE0MDIGA1UEAxMrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALvM0mJ+SUfzucssEnjoZllnezjKC25YeIhk3iIUzlaJ/uXueESt9GEA3lAo6W/bt73R0zu10u4uhM5MC27FDoq9u7oaqBRhll0gGaz6HDqG0haCwuZdDb0ikalbaaAAzh3AIefby26/Hc98bRBBsf6pS083xX1ogiGFosteQtqKNXjT8c0Hzr3bu2Hrejn+JrrFdBLOf5jRE6XhzlRK4vD1n8c8OPOVByI97KHJeC5PyN4g8h34KU+PbSCWIRxOTSJizXcDIWtXAQiGyTMtXSQn3aCvNux4vaisgZn7TUD4XsxlUbDo7H9gX1Bsxj+aQhqxQYxDnC4Y/94/kyXm4L0CAwEAAaOBtTCBsjAOBgNVHQ8BAf8EBAMCBaAwCQYDVR0TBAIwADAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwNgYDVR0RBC8wLYIrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTAfBgNVHSMEGDAWgBSmWMP5CXuaSzoLKwcLXYZnoeCJmDAdBgNVHQ4EFgQUpljD+Ql7mks6CysHC12GZ6HgiZgwDQYJKoZIhvcNAQELBQADggEBAINwybFwYpXJkvauL5QbtrykIDYeP8oFdVIeVY8YI9MGfx7OwWDsNBVXv2B62zAZ49hK5G87++NmFI/FHnGOCISDYoJkRSCy2Nbeyr7Nx2VykWzUQqHLZfvr5KqW4Gj1OFHUqTl8lP3FWDd/P+lil3JobaSiICQshgF0GnX2a8ji8mfXpJSP20gzrLw84brmtmheAvJ9X/sLbM/RBkkT6g4NV2QbTMqo6k601qBNQBsH+lTDDWPCkRoAlW6a0z9bWIhGHWJ2lcR70zagcxIVl5/Fq35770/aMGroSrIx3JayOEqsvgIthYBKHzpT2VFwUz1VpBpNVJg9/u6jCwLY7QA="], + "x5t": "Hyq4NATAjsnqC7mdrtAhhrCR2_Q" + }, { + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": "001DDCD014A848E8824577B3E4F3AEDB3BCF5FFD", + "n": "sI_r4iOwvRxksSovyZN8da5u-dh07fdcqh7FjyKKZCOVr7da898xk0TG9eZ7lfA1CmBTH4sX5evg4Yg2xdFDxYK4xmLZcwMyQZIDiZcdIujnttaqplrMv_v-YyAapHFmudbBO8NVuOH3gmGaJ02G8u1Vdf8C3PdNK13ch4wpNvyoxwqaIWGPSzudA6mGPGovRLhu5dEOOJSJtsLzExNvNmHnhPJZk06r7FePkBWSQ1CCHXAzpB-aUWEZC1FKMSiq2dvfOCyiJttEdyj8O_5yqb0wLAPb-8NdzkppbRal2WGowoU-AejqoWImhfDzlOBQStnhuAluKpA6sH0ifKlQsQ", + "e": "AQAB", + "x5c": ["MIIDrDCCApSgAwIBAgIQKiyRrA01T5qtxdzvZ/ErzjANBgkqhkiG9w0BAQsFADA2MTQwMgYDVQQDEyt2c3RzLXZzdHNnaHJ0LWdoLXZzby1vYXV0aC52aXN1YWxzdHVkaW8uY29tMB4XDTIzMTAxODE1MDExOFoXDTI1MTAxODE1MTExOFowNjE0MDIGA1UEAxMrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALCP6+IjsL0cZLEqL8mTfHWubvnYdO33XKoexY8iimQjla+3WvPfMZNExvXme5XwNQpgUx+LF+Xr4OGINsXRQ8WCuMZi2XMDMkGSA4mXHSLo57bWqqZazL/7/mMgGqRxZrnWwTvDVbjh94JhmidNhvLtVXX/Atz3TStd3IeMKTb8qMcKmiFhj0s7nQOphjxqL0S4buXRDjiUibbC8xMTbzZh54TyWZNOq+xXj5AVkkNQgh1wM6QfmlFhGQtRSjEoqtnb3zgsoibbRHco/Dv+cqm9MCwD2/vDXc5KaW0WpdlhqMKFPgHo6qFiJoXw85TgUErZ4bgJbiqQOrB9InypULECAwEAAaOBtTCBsjAOBgNVHQ8BAf8EBAMCBaAwCQYDVR0TBAIwADAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwNgYDVR0RBC8wLYIrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTAfBgNVHSMEGDAWgBQ45rBfvl4JJ7vg3WgLjQTfhDihvzAdBgNVHQ4EFgQUOOawX75eCSe74N1oC40E34Q4ob8wDQYJKoZIhvcNAQELBQADggEBABdN6HPheRdzwvJgi4xGHnf9pvlUC8981kAtgHnPT0VEYXh/dCMnKJSvCDJADpdmkuKxLxAfACeZR2CUHkQ0eO1ek/ihLvPqywDhLENq6Lvzu3qlhvUPBkGYjydpLtXQ1bBXUQ1FzT5/L1U19P2rJso9mC4ltu2OHJ9NLCKG0zffBItAJqhAiXtKbCUg4c9RbQxi9T2/xr9R72di4Qygfnmr3QleAqmjRG918cm5/uJ0s5EaK3QI7GQy7+tc44o3H3AI5eFtrHwIV0zoY4A9YIsaRmMHq9soHFBEO1HDKKRUOl/4tjpx8zHpp5Clz0wiZMgvSIdBa3/fTeUJ3flUYMo="], + "x5t": "AB3c0BSoSOiCRXez5POu2zvPX_0" + }] + }"#; + + let _config_mock = server + .mock("GET", "/.well-known/openid-configuration") + .with_header("content-type", "application/json") + .with_body(config) + .create(); + + let _jwks_mock = server + .mock("GET", "/.well-known/jwks") + .with_header("content-type", "application/json") + .with_body(jwks) + .create(); + + let client = Client::new(); + let jwks = load_jwks(&client, &issuer_url).await.unwrap(); + + assert_debug_snapshot!(jwks); + } +} diff --git a/crates/crates_io_trusted_publishing/src/keystore/mod.rs b/crates/crates_io_trusted_publishing/src/keystore/mod.rs new file mode 100644 index 00000000000..0a61bcec3ec --- /dev/null +++ b/crates/crates_io_trusted_publishing/src/keystore/mod.rs @@ -0,0 +1,38 @@ +mod r#impl; +mod load_jwks; + +use async_trait::async_trait; +pub use r#impl::RealOidcKeyStore; +use jsonwebtoken::DecodingKey; + +/// A trait for fetching OIDC keys from a key store. +/// +/// The main implementation is [`RealOidcKeyStore`], but for testing purposes +/// there is also a mock implementation available. +#[cfg_attr(feature = "mock", mockall::automock)] +#[async_trait] +pub trait OidcKeyStore: Send + Sync { + /// Fetches a [`DecodingKey`] from the key store using the provided `key_id`. + /// + /// If the key is not found on the server, it will return `None`. If there + /// is an error while fetching the key, it will return an error. + async fn get_oidc_key(&self, key_id: &str) -> anyhow::Result>; +} + +#[cfg(feature = "mock")] +impl MockOidcKeyStore { + /// Creates a new instance of [`MockOidcKeyStore`] based on the RSA keys + /// provided in the [`crate::test_keys`] module. + pub fn with_test_key() -> Self { + use crate::test_keys::{DECODING_KEY, KEY_ID}; + use mockall::predicate::*; + + let mut mock = Self::new(); + + mock.expect_get_oidc_key() + .with(eq(KEY_ID)) + .returning(|_| Ok(Some(DECODING_KEY.clone()))); + + mock + } +} diff --git a/crates/crates_io_trusted_publishing/src/keystore/snapshots/crates_io_oidc__keystore__load_jwks__tests__load_jwks.snap b/crates/crates_io_trusted_publishing/src/keystore/snapshots/crates_io_oidc__keystore__load_jwks__tests__load_jwks.snap new file mode 100644 index 00000000000..ca53e10b6d5 --- /dev/null +++ b/crates/crates_io_trusted_publishing/src/keystore/snapshots/crates_io_oidc__keystore__load_jwks__tests__load_jwks.snap @@ -0,0 +1,126 @@ +--- +source: crates/crates_io_trusted_publishing/src/keystore/load_jwks.rs +expression: jwks +--- +JwkSet { + keys: [ + Jwk { + common: CommonParameters { + public_key_use: Some( + Signature, + ), + key_operations: None, + key_algorithm: Some( + RS256, + ), + key_id: Some( + "cc413527-173f-5a05-976e-9c52b1d7b431", + ), + x509_url: None, + x509_chain: None, + x509_sha1_fingerprint: None, + x509_sha256_fingerprint: None, + }, + algorithm: RSA( + RSAKeyParameters { + key_type: RSA, + n: "w4M936N3ZxNaEblcUoBm-xu0-V9JxNx5S7TmF0M3SBK-2bmDyAeDdeIOTcIVZHG-ZX9N9W0u1yWafgWewHrsz66BkxXq3bscvQUTAw7W3s6TEeYY7o9shPkFfOiU3x_KYgOo06SpiFdymwJflRs9cnbaU88i5fZJmUepUHVllP2tpPWTi-7UA3AdP3cdcCs5bnFfTRKzH2W0xqKsY_jIG95aQJRBDpbiesefjuyxcQnOv88j9tCKWzHpJzRKYjAUM6OPgN4HYnaSWrPJj1v41eEkFM1kORuj-GSH2qMVD02VklcqaerhQHIqM-RjeHsN7G05YtwYzomE5G-fZuwgvQ", + e: "AQAB", + }, + ), + }, + Jwk { + common: CommonParameters { + public_key_use: Some( + Signature, + ), + key_operations: None, + key_algorithm: Some( + RS256, + ), + key_id: Some( + "38826b17-6a30-5f9b-b169-8beb8202f723", + ), + x509_url: None, + x509_chain: Some( + [ + "MIIDKzCCAhOgAwIBAgIUDnwm6eRIqGFA3o/P1oBrChvx/nowDQYJKoZIhvcNAQELBQAwJTEjMCEGA1UEAwwaYWN0aW9ucy5zZWxmLXNpZ25lZC5naXRodWIwHhcNMjQwMTIzMTUyNTM2WhcNMzQwMTIwMTUyNTM2WjAlMSMwIQYDVQQDDBphY3Rpb25zLnNlbGYtc2lnbmVkLmdpdGh1YjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOTGp5svs8LJN8BH7VzXShWXnOK0lhDVuI0xnr5bwHFPc924CwaIEFb6mC7bvW2lZtgd633uaJ2naG6vKaOVGpCdGLE4ohH11nUk+2CNknZL7/oTmDHGSmGeHRb7kjtb0Ng4BJMPzmTYmCNUudfDFhHDcZz1Obuu85GsABrC5ZlzWzspYFXwUSaxvII+rHK/rAbOC2gmt5IOSLmgh3taQfp0mB6Lxlf89HoBPNwtPfBX8DtXTWQVnqODm4W+WfmWBSyXGX54DGNMyZwlTZqR0FjoMXxopId3MIuDGKxa2weDU5cW60N2y/qxikeV99fL3sg5aPA8s9iljKG0+MAfVNUCAwEAAaNTMFEwHQYDVR0OBBYEFIPALo5VanJ6E1B9eLQgGO+uGV65MB8GA1UdIwQYMBaAFIPALo5VanJ6E1B9eLQgGO+uGV65MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGS0hZE+DqKIRi49Z2KDOMOaSZnAYgqq6ws9HJHT09MXWlMHB8E/apvy2ZuFrcSu14ZLweJid+PrrooXEXEO6azEakzCjeUb9G1QwlzP4CkTcMGCw1Snh3jWZIuKaw21f7mp2rQ+YNltgHVDKY2s8AD273E8musEsWxJl80/MNvMie8Hfh4n4/Xl2r6t1YPmUJMoXAXdTBb0hkPy1fUu3r2T+1oi7Rw6kuVDfAZjaHupNHzJeDOg2KxUoK/GF2/M2qpVrd19Pv/JXNkQXRE4DFbErMmA7tXpp1tkXJRPhFui/Pv5H9cPgObEf9x6W4KnCXzT3ReeeRDKF8SqGTPELsc=", + ], + ), + x509_sha1_fingerprint: Some( + "ykNaY4qM_ta4k2TgZOCEYLkcYlA", + ), + x509_sha256_fingerprint: None, + }, + algorithm: RSA( + RSAKeyParameters { + key_type: RSA, + n: "5Manmy-zwsk3wEftXNdKFZec4rSWENW4jTGevlvAcU9z3bgLBogQVvqYLtu9baVm2B3rfe5onadobq8po5UakJ0YsTiiEfXWdST7YI2Sdkvv-hOYMcZKYZ4dFvuSO1vQ2DgEkw_OZNiYI1S518MWEcNxnPU5u67zkawAGsLlmXNbOylgVfBRJrG8gj6scr-sBs4LaCa3kg5IuaCHe1pB-nSYHovGV_z0egE83C098FfwO1dNZBWeo4Obhb5Z-ZYFLJcZfngMY0zJnCVNmpHQWOgxfGikh3cwi4MYrFrbB4NTlxbrQ3bL-rGKR5X318veyDlo8Dyz2KWMobT4wB9U1Q", + e: "AQAB", + }, + ), + }, + Jwk { + common: CommonParameters { + public_key_use: Some( + Signature, + ), + key_operations: None, + key_algorithm: Some( + RS256, + ), + key_id: Some( + "1F2AB83404C08EC9EA0BB99DAED02186B091DBF4", + ), + x509_url: None, + x509_chain: Some( + [ + "MIIDrDCCApSgAwIBAgIQAP4blP36Q3WmMOhWf0RBMzANBgkqhkiG9w0BAQsFADA2MTQwMgYDVQQDEyt2c3RzLXZzdHNnaHJ0LWdoLXZzby1vYXV0aC52aXN1YWxzdHVkaW8uY29tMB4XDTIzMTAyNDE0NTI1NVoXDTI1MTAyNDE1MDI1NVowNjE0MDIGA1UEAxMrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALvM0mJ+SUfzucssEnjoZllnezjKC25YeIhk3iIUzlaJ/uXueESt9GEA3lAo6W/bt73R0zu10u4uhM5MC27FDoq9u7oaqBRhll0gGaz6HDqG0haCwuZdDb0ikalbaaAAzh3AIefby26/Hc98bRBBsf6pS083xX1ogiGFosteQtqKNXjT8c0Hzr3bu2Hrejn+JrrFdBLOf5jRE6XhzlRK4vD1n8c8OPOVByI97KHJeC5PyN4g8h34KU+PbSCWIRxOTSJizXcDIWtXAQiGyTMtXSQn3aCvNux4vaisgZn7TUD4XsxlUbDo7H9gX1Bsxj+aQhqxQYxDnC4Y/94/kyXm4L0CAwEAAaOBtTCBsjAOBgNVHQ8BAf8EBAMCBaAwCQYDVR0TBAIwADAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwNgYDVR0RBC8wLYIrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTAfBgNVHSMEGDAWgBSmWMP5CXuaSzoLKwcLXYZnoeCJmDAdBgNVHQ4EFgQUpljD+Ql7mks6CysHC12GZ6HgiZgwDQYJKoZIhvcNAQELBQADggEBAINwybFwYpXJkvauL5QbtrykIDYeP8oFdVIeVY8YI9MGfx7OwWDsNBVXv2B62zAZ49hK5G87++NmFI/FHnGOCISDYoJkRSCy2Nbeyr7Nx2VykWzUQqHLZfvr5KqW4Gj1OFHUqTl8lP3FWDd/P+lil3JobaSiICQshgF0GnX2a8ji8mfXpJSP20gzrLw84brmtmheAvJ9X/sLbM/RBkkT6g4NV2QbTMqo6k601qBNQBsH+lTDDWPCkRoAlW6a0z9bWIhGHWJ2lcR70zagcxIVl5/Fq35770/aMGroSrIx3JayOEqsvgIthYBKHzpT2VFwUz1VpBpNVJg9/u6jCwLY7QA=", + ], + ), + x509_sha1_fingerprint: Some( + "Hyq4NATAjsnqC7mdrtAhhrCR2_Q", + ), + x509_sha256_fingerprint: None, + }, + algorithm: RSA( + RSAKeyParameters { + key_type: RSA, + n: "u8zSYn5JR_O5yywSeOhmWWd7OMoLblh4iGTeIhTOVon-5e54RK30YQDeUCjpb9u3vdHTO7XS7i6EzkwLbsUOir27uhqoFGGWXSAZrPocOobSFoLC5l0NvSKRqVtpoADOHcAh59vLbr8dz3xtEEGx_qlLTzfFfWiCIYWiy15C2oo1eNPxzQfOvdu7Yet6Of4musV0Es5_mNETpeHOVEri8PWfxzw485UHIj3socl4Lk_I3iDyHfgpT49tIJYhHE5NImLNdwMha1cBCIbJMy1dJCfdoK827Hi9qKyBmftNQPhezGVRsOjsf2BfUGzGP5pCGrFBjEOcLhj_3j-TJebgvQ", + e: "AQAB", + }, + ), + }, + Jwk { + common: CommonParameters { + public_key_use: Some( + Signature, + ), + key_operations: None, + key_algorithm: Some( + RS256, + ), + key_id: Some( + "001DDCD014A848E8824577B3E4F3AEDB3BCF5FFD", + ), + x509_url: None, + x509_chain: Some( + [ + "MIIDrDCCApSgAwIBAgIQKiyRrA01T5qtxdzvZ/ErzjANBgkqhkiG9w0BAQsFADA2MTQwMgYDVQQDEyt2c3RzLXZzdHNnaHJ0LWdoLXZzby1vYXV0aC52aXN1YWxzdHVkaW8uY29tMB4XDTIzMTAxODE1MDExOFoXDTI1MTAxODE1MTExOFowNjE0MDIGA1UEAxMrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALCP6+IjsL0cZLEqL8mTfHWubvnYdO33XKoexY8iimQjla+3WvPfMZNExvXme5XwNQpgUx+LF+Xr4OGINsXRQ8WCuMZi2XMDMkGSA4mXHSLo57bWqqZazL/7/mMgGqRxZrnWwTvDVbjh94JhmidNhvLtVXX/Atz3TStd3IeMKTb8qMcKmiFhj0s7nQOphjxqL0S4buXRDjiUibbC8xMTbzZh54TyWZNOq+xXj5AVkkNQgh1wM6QfmlFhGQtRSjEoqtnb3zgsoibbRHco/Dv+cqm9MCwD2/vDXc5KaW0WpdlhqMKFPgHo6qFiJoXw85TgUErZ4bgJbiqQOrB9InypULECAwEAAaOBtTCBsjAOBgNVHQ8BAf8EBAMCBaAwCQYDVR0TBAIwADAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwNgYDVR0RBC8wLYIrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTAfBgNVHSMEGDAWgBQ45rBfvl4JJ7vg3WgLjQTfhDihvzAdBgNVHQ4EFgQUOOawX75eCSe74N1oC40E34Q4ob8wDQYJKoZIhvcNAQELBQADggEBABdN6HPheRdzwvJgi4xGHnf9pvlUC8981kAtgHnPT0VEYXh/dCMnKJSvCDJADpdmkuKxLxAfACeZR2CUHkQ0eO1ek/ihLvPqywDhLENq6Lvzu3qlhvUPBkGYjydpLtXQ1bBXUQ1FzT5/L1U19P2rJso9mC4ltu2OHJ9NLCKG0zffBItAJqhAiXtKbCUg4c9RbQxi9T2/xr9R72di4Qygfnmr3QleAqmjRG918cm5/uJ0s5EaK3QI7GQy7+tc44o3H3AI5eFtrHwIV0zoY4A9YIsaRmMHq9soHFBEO1HDKKRUOl/4tjpx8zHpp5Clz0wiZMgvSIdBa3/fTeUJ3flUYMo=", + ], + ), + x509_sha1_fingerprint: Some( + "AB3c0BSoSOiCRXez5POu2zvPX_0", + ), + x509_sha256_fingerprint: None, + }, + algorithm: RSA( + RSAKeyParameters { + key_type: RSA, + n: "sI_r4iOwvRxksSovyZN8da5u-dh07fdcqh7FjyKKZCOVr7da898xk0TG9eZ7lfA1CmBTH4sX5evg4Yg2xdFDxYK4xmLZcwMyQZIDiZcdIujnttaqplrMv_v-YyAapHFmudbBO8NVuOH3gmGaJ02G8u1Vdf8C3PdNK13ch4wpNvyoxwqaIWGPSzudA6mGPGovRLhu5dEOOJSJtsLzExNvNmHnhPJZk06r7FePkBWSQ1CCHXAzpB-aUWEZC1FKMSiq2dvfOCyiJttEdyj8O_5yqb0wLAPb-8NdzkppbRal2WGowoU-AejqoWImhfDzlOBQStnhuAluKpA6sH0ifKlQsQ", + e: "AQAB", + }, + ), + }, + ], +} diff --git a/crates/crates_io_trusted_publishing/src/lib.rs b/crates/crates_io_trusted_publishing/src/lib.rs new file mode 100644 index 00000000000..7e1d1c3c1e5 --- /dev/null +++ b/crates/crates_io_trusted_publishing/src/lib.rs @@ -0,0 +1,9 @@ +#![doc = include_str!("../README.md")] + +pub mod github; +pub mod keystore; +#[cfg(feature = "mock")] +pub mod test_keys; +pub mod unverified; + +pub const EXPECTED_AUDIENCE: &str = "crates.io"; diff --git a/crates/crates_io_trusted_publishing/src/test_keys.rs b/crates/crates_io_trusted_publishing/src/test_keys.rs new file mode 100644 index 00000000000..e84e9146022 --- /dev/null +++ b/crates/crates_io_trusted_publishing/src/test_keys.rs @@ -0,0 +1,70 @@ +//! This module contains a set of RSA keys that must be used only for +//! testing purposes. The keys are not secure and must not be used +//! in production! + +use jsonwebtoken::errors::Error; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey}; +use serde::Serialize; +use std::sync::LazyLock; + +const PRIVATE_KEY_PEM: &[u8] = br#" +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCG7Za7JLYjNrWK +2dDl1Hg2lkBxBJSR4KfMVQ8PyN/hz6GGwTondSLTUVh9BzKfClnxcThqmv6awKZV +gv2ZV9FBUtOyromyZomdLRmYYzA8FLgXmXxqJqit8jQIAbpDGHz2qRdh4PITyEPl +Oib+hdhbQIOPK27xmPHXcQJ3gQoHoiCDkXbhDgztYM6BBKnSGaQPnW81p4pWEsFk +fhsmDKDm07EI/l96IQXbGlETna41+dtVmz83nL4DJ7jJUxuAlH8iH6w+4aMhjDmK +j7y4m394ceq0IWbptKZ7T/ewpKsbSzR14UuvBtLNeArSm5WqUmemxiWjeTo155Dp +c8tnMG6BAgMBAAECggEAf0tFCje/Ugd6TI3kRAAoja9BCp78n4eoJuEUfZrQhRRC +2oQPnkwnV+AFsKcKvfqhEmTzibfCfjNEeaZEJNgxxgQjTw7VP6b3K37yB8+EIRqW +90TJmMfyGXFIX0lp9YTz2C18rs3u9HTagTdUtImHrcd2lqquV2You02VuzLVSI7q +q4NvY1dHPRNo5g42EDhRVWjKPVq21EMGSsawSz/Y5jHoDIG4VRCqN1tuOwmKQw03 +6ldStckDshRcb6pfFsrsfC0YHqXM2SSwS15C2NlEMIzVITKXaHCf5ole08+F5kmU +ADav+hHONHogKf2zsb2rd9khqbRgEEZl5ArbtudgwQKBgQDfUJYeLRZaDxhnlhP7 +nfSWw0uYuXUu0LxsknHQMttC5YOKZRHl3RYHWSfNHMCe66geK5VykxSNOw2bVACm +hwJr4JZYgk3opLhvyvdRJ8NuoI4JTo24CoEF8EKHhJGPHMXQSre3JmK8ptPep9+P +/gTXT/U3Vlf9puZkoppq+/IEyQKBgQCarTptO3caJoCFlYnrCZ3X28LPv37tFjmE +AHRL5wxeFWhSZzemVul3v4vZvXgyc+VOBQoFvkQba7DncA1WVbl+zLgu8QZFotFf +VI3bZAK+02wqLXIo1CnAMB921Vn3UrHItToiCOJTHSEalxlTDEkSEMxx09sYGFh5 +REIcQXIP+QKBgHW9zYiXiSNuthVXoa2WuLEMwz0A+3H1iINOK0f0qHp6/IHpjChA +Cy9QqJWSxVSFN5zAqglA1yMnsaLmBXnH0VUDkwGTonQ49S2sO/3EE1yutnTdwAb7 +Ms/ov4soMH7eUsXhvz+Hs6N36lmI9Wy8J91GQSouEjKg3vTMbtJdiFtRAoGBAJYA +60mlwsKsljV2qXM0N0xwxoP8/ZXl2M+INUCrCJZxgmNv0EtTvEUykOkQU3HybW31 +exvIwnopPT2lsHmK10L+PJzhiCieVxhxgsVCP1ta5Goe+rhX0UmeIdV34TD2lI3G +G2OIZB0ggcsswBWHM5H+kpbNU4wRiDPKm6aVXY3ZAoGAVJPyITFs2foRiRG1S8o9 +gfz6rWleaXO2OmFh5P3UehhLwMr+vjvZn+8VByUubA9wqnY2JWu9ZSvdbdP6L6Z4 +usn0CLeCS1Gdbk4piqiSmUAe7nt2Sh258SVG5deDX6ej06NQzy249TtufxXjZ/3y +68y3i6u6aIE4wCiMYXl9B0o= +-----END PRIVATE KEY----- +"#; + +const PUBLIC_KEY_JWK: &str = r#" +{ + "kty": "RSA", + "e": "AQAB", + "use": "sig", + "kid": "c0ffee", + "alg": "RS256", + "n": "hu2WuyS2Iza1itnQ5dR4NpZAcQSUkeCnzFUPD8jf4c-hhsE6J3Ui01FYfQcynwpZ8XE4apr-msCmVYL9mVfRQVLTsq6JsmaJnS0ZmGMwPBS4F5l8aiaorfI0CAG6Qxh89qkXYeDyE8hD5Tom_oXYW0CDjytu8Zjx13ECd4EKB6Igg5F24Q4M7WDOgQSp0hmkD51vNaeKVhLBZH4bJgyg5tOxCP5feiEF2xpRE52uNfnbVZs_N5y-Aye4yVMbgJR_Ih-sPuGjIYw5io-8uJt_eHHqtCFm6bSme0_3sKSrG0s0deFLrwbSzXgK0puVqlJnpsYlo3k6NeeQ6XPLZzBugQ" +} +"#; + +pub(crate) const KEY_ID: &str = "c0ffee"; + +static ENCODING_KEY: LazyLock = + LazyLock::new(|| EncodingKey::from_rsa_pem(PRIVATE_KEY_PEM).unwrap()); + +pub(crate) static DECODING_KEY: LazyLock = LazyLock::new(|| { + let jwk = serde_json::from_str(PUBLIC_KEY_JWK).unwrap(); + DecodingKey::from_jwk(&jwk).unwrap() +}); + +pub fn encode_for_testing(claims: &impl Serialize) -> Result { + let header = jsonwebtoken::Header { + alg: Algorithm::RS256, + kid: Some(KEY_ID.into()), + ..Default::default() + }; + + jsonwebtoken::encode(&header, claims, &ENCODING_KEY) +} diff --git a/crates/crates_io_trusted_publishing/src/unverified.rs b/crates/crates_io_trusted_publishing/src/unverified.rs new file mode 100644 index 00000000000..782f83db8d5 --- /dev/null +++ b/crates/crates_io_trusted_publishing/src/unverified.rs @@ -0,0 +1,34 @@ +use jsonwebtoken::errors::Error; +use jsonwebtoken::{DecodingKey, TokenData, Validation}; +use serde::Deserialize; +use std::sync::LazyLock; + +/// [`Validation`] configuration for decoding JWTs without any +/// signature validation. +/// +/// This must only be used to extract the `iss` claim from the JWT, which +/// is then used to look up the corresponding OIDC key set. +static NO_VALIDATION: LazyLock = LazyLock::new(|| { + let mut no_validation = Validation::default(); + no_validation.validate_aud = false; + no_validation.validate_exp = false; + no_validation.insecure_disable_signature_validation(); + no_validation +}); + +/// Empty [`DecodingKey`] used for decoding JWTs without any signature +/// validation. +/// +/// See [`NO_VALIDATION`] for more details. +static EMPTY_KEY: LazyLock = LazyLock::new(|| DecodingKey::from_secret(b"")); + +#[derive(Debug, Deserialize)] +pub struct UnverifiedClaims { + pub iss: String, +} + +impl UnverifiedClaims { + pub fn decode(token: &str) -> Result, Error> { + jsonwebtoken::decode(token, &EMPTY_KEY, &NO_VALIDATION) + } +} diff --git a/src/app.rs b/src/app.rs index 079d433b8d3..17cd3decfb6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,6 +12,7 @@ use crate::storage::{Storage, StorageConfig}; use axum::extract::{FromRef, FromRequestParts, State}; use bon::Builder; use crates_io_github::GitHubClient; +use crates_io_trusted_publishing::keystore::OidcKeyStore; use deadpool_diesel::Runtime; use derive_more::Deref; use diesel_async::AsyncPgConnection; @@ -42,6 +43,9 @@ pub struct App { pub github_oauth: BasicClient, + /// OIDC key stores for trusted publishing + pub oidc_key_stores: HashMap>, + /// The server configuration pub config: Arc, diff --git a/src/bin/server.rs b/src/bin/server.rs index 3303158c7e1..12f536606d9 100644 --- a/src/bin/server.rs +++ b/src/bin/server.rs @@ -1,16 +1,18 @@ #[macro_use] extern crate tracing; +use axum::ServiceExt; use crates_io::middleware::normalize_path::normalize_path; use crates_io::{App, Emails, metrics::LogEncoder}; -use std::{sync::Arc, time::Duration}; - -use axum::ServiceExt; use crates_io_github::RealGitHubClient; +use crates_io_trusted_publishing::github::GITHUB_ISSUER_URL; +use crates_io_trusted_publishing::keystore::{OidcKeyStore, RealOidcKeyStore}; use prometheus::Encoder; use reqwest::Client; +use std::collections::HashMap; use std::io::Write; use std::net::SocketAddr; +use std::{sync::Arc, time::Duration}; use tokio::net::TcpListener; use tokio::signal::unix::{SignalKind, signal}; use tower::Layer; @@ -33,10 +35,15 @@ fn main() -> anyhow::Result<()> { let github = RealGitHubClient::new(client); let github = Box::new(github); + let github_key_store: Box = Box::new(RealOidcKeyStore::github()); + let oidc_key_stores: HashMap> = + HashMap::from([(GITHUB_ISSUER_URL.into(), github_key_store)]); + let app = App::builder() .databases_from_config(&config.db) .github(github) .github_oauth_from_config(&config) + .oidc_key_stores(oidc_key_stores) .emails(emails) .storage_from_config(&config.storage) .rate_limiter_from_config(config.rate_limiter.clone()) diff --git a/src/controllers.rs b/src/controllers.rs index 5563b2eaced..85d75f1845e 100644 --- a/src/controllers.rs +++ b/src/controllers.rs @@ -5,9 +5,11 @@ pub mod category; pub mod crate_owner_invitation; pub mod git; pub mod github; +pub mod github_oidc_configs; pub mod keyword; pub mod krate; pub mod metrics; +pub mod oidc_tokens; pub mod session; pub mod site_metadata; pub mod summary; diff --git a/src/controllers/github_oidc_configs.rs b/src/controllers/github_oidc_configs.rs new file mode 100644 index 00000000000..0d1a54e250b --- /dev/null +++ b/src/controllers/github_oidc_configs.rs @@ -0,0 +1,119 @@ +use crate::app::AppState; +use crate::auth::AuthCheck; +use crate::controllers::krate::load_crate; +use crate::util::errors::AppResult; +use axum::Json; +use chrono::{DateTime, Utc}; +use crates_io_database::models::trusted_publishing::NewGitHubConfig; +use http::request::Parts; +use oauth2::AccessToken; +use secrecy::ExposeSecret; + +/// Create a new Trusted Publishing configuration for GitHub Actions. +#[utoipa::path( + put, + path = "/api/v1/github_oidc_configs", + security(("cookie" = [])), + tag = "oidc", + responses((status = 200, description = "Successful Response", body = inline(json::CreateResponse))), +)] +pub async fn create_github_oidc_config( + state: AppState, + parts: Parts, + Json(json): Json, +) -> AppResult> { + let json_config = json.github_oidc_config; + json_config.validate()?; + + let crate_name = json_config.krate; + let owner = json_config.repository_owner; + let repo = json_config.repository_name; + + let mut conn = state.db_write().await?; + + let auth = AuthCheck::only_cookie().check(&parts, &mut conn).await?; + + let krate = load_crate(&mut conn, &crate_name).await?; + + // TODO authZ + + // Lookup `repository_owner_id` via GitHub API + + let token = AccessToken::new(auth.user().gh_access_token.expose_secret().to_string()); + let repository_details = state.github.get_repository(&owner, &repo, &token).await?; + // TODO error handling + + // Save the new GitHub OIDC config to the database + + let new_config = NewGitHubConfig { + crate_id: krate.id, + repository_owner: &owner, + repository_owner_id: repository_details.owner.id, + repository_name: &repo, + workflow_filename: &json_config.workflow_filename, + environment: json_config.environment.as_deref(), + }; + + let saved_config = new_config.insert(&mut conn).await?; + // TODO error handling + + // TODO send notification emails to crate owners + + Ok(Json(json::CreateResponse { + github_oidc_config: json::GitHubOidcConfig { + id: saved_config.id, + krate: crate_name, + repository_owner: saved_config.repository_owner, + repository_owner_id: saved_config.repository_owner_id, + repository_name: saved_config.repository_name, + workflow_filename: saved_config.workflow_filename, + environment: saved_config.environment, + created_at: saved_config.created_at, + }, + })) +} + +mod json { + use super::*; + + #[derive(Debug, Serialize, utoipa::ToSchema)] + pub struct GitHubOidcConfig { + pub id: i32, + #[serde(rename = "crate")] + pub krate: String, + pub repository_owner: String, + pub repository_owner_id: i32, + pub repository_name: String, + pub workflow_filename: String, + pub environment: Option, + pub created_at: DateTime, + // TODO created_by? + } + + #[derive(Debug, Deserialize)] + pub struct NewGitHubOidcConfig { + #[serde(rename = "crate")] + pub krate: String, + pub repository_owner: String, + pub repository_name: String, + pub workflow_filename: String, + pub environment: Option, + } + + impl NewGitHubOidcConfig { + pub fn validate(&self) -> AppResult<()> { + // TODO implement validation logic (see PyPI) + Ok(()) + } + } + + #[derive(Debug, Deserialize)] + pub struct CreateRequest { + pub github_oidc_config: NewGitHubOidcConfig, + } + + #[derive(Debug, Serialize, utoipa::ToSchema)] + pub struct CreateResponse { + pub github_oidc_config: GitHubOidcConfig, + } +} diff --git a/src/controllers/krate/publish.rs b/src/controllers/krate/publish.rs index 0e5a3b78cb1..efaf6d35daa 100644 --- a/src/controllers/krate/publish.rs +++ b/src/controllers/krate/publish.rs @@ -1,7 +1,11 @@ //! Functionality related to publishing a new crate or version of a crate. use crate::app::AppState; -use crate::auth::AuthCheck; +use crate::auth::{AuthCheck, Authentication}; +use crate::models::{ + Category, Crate, DependencyKind, Keyword, NewCrate, NewVersion, NewVersionOwnerAction, + VersionAction, default_versions::Version as DefaultVersion, +}; use crate::worker::jobs::{ self, CheckTyposquat, SendPublishNotificationsJob, UpdateDefaultVersion, }; @@ -11,7 +15,7 @@ use cargo_manifest::{Dependency, DepsSet, TargetDepsSet}; use chrono::{DateTime, SecondsFormat, Utc}; use crates_io_tarball::{TarballError, process_tarball}; use crates_io_worker::{BackgroundJob, EnqueueError}; -use diesel::dsl::{exists, select}; +use diesel::dsl::{exists, now, select}; use diesel::prelude::*; use diesel::sql_types::Timestamptz; use diesel_async::scoped_futures::ScopedFutureExt; @@ -19,19 +23,14 @@ use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl}; use futures_util::TryFutureExt; use futures_util::TryStreamExt; use hex::ToHex; -use http::StatusCode; use http::request::Parts; +use http::{StatusCode, header}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use tokio::io::{AsyncRead, AsyncReadExt}; use tokio_util::io::StreamReader; use url::Url; -use crate::models::{ - Category, Crate, DependencyKind, Keyword, NewCrate, NewVersion, NewVersionOwnerAction, - VersionAction, default_versions::Version as DefaultVersion, -}; - use crate::controllers::helpers::authorization::Rights; use crate::licenses::parse_license_expr; use crate::middleware::log_request::RequestLogExt; @@ -51,6 +50,11 @@ const MISSING_RIGHTS_ERROR_MESSAGE: &str = "this crate exists but you don't seem const MAX_DESCRIPTION_LENGTH: usize = 1000; +enum AuthType { + Regular(Authentication), + Oidc(), +} + /// Publish a new crate/version. /// /// Used by `cargo publish` to publish a new crate or to publish a new version of an @@ -130,30 +134,62 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult EndpointScope::PublishNew, }; - let auth = AuthCheck::default() - .with_endpoint_scope(endpoint_scope) - .for_crate(&metadata.name) - .check(&req, &mut conn) - .await?; + let bearer = req + .headers + .get(header::AUTHORIZATION) + .and_then(|h| h.as_bytes().strip_prefix(b"Bearer ")); - let verified_email_address = auth.user().verified_email(&mut conn).await?; - let verified_email_address = verified_email_address.ok_or_else(|| { - bad_request(format!( - "A verified email address is required to publish crates to crates.io. \ - Visit https://{}/settings/profile to set and verify your email address.", - app.config.domain_name, - )) - })?; + let auth = match (bearer, &existing_crate) { + (Some(bearer), Some(existing_crate)) if bearer.starts_with(b"crates.io/oidc/") => { + let hashed_token = Sha256::digest(bearer); + + trustpub_tokens::table + .filter(trustpub_tokens::crate_ids.contains(vec![existing_crate.id])) + .filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice())) + .filter(trustpub_tokens::expires_at.gt(now)) + .select(trustpub_tokens::id) + .get_result::(&mut conn) + .await?; - // Use a different rate limit whether this is a new or an existing crate. - let rate_limit_action = match existing_crate { - Some(_) => LimitedAction::PublishUpdate, - None => LimitedAction::PublishNew, + AuthType::Oidc() + } + _ => { + let auth = AuthCheck::default() + .with_endpoint_scope(endpoint_scope) + .for_crate(&metadata.name) + .check(&req, &mut conn) + .await?; + + AuthType::Regular(auth) + } }; - app.rate_limiter - .check_rate_limit(auth.user().id, rate_limit_action, &mut conn) - .await?; + let verified_email_address = match &auth { + AuthType::Regular(auth) => { + let verified_email_address = auth.user().verified_email(&mut conn).await?; + let verified_email_address = verified_email_address.ok_or_else(|| { + bad_request(format!( + "A verified email address is required to publish crates to crates.io. \ + Visit https://{}/settings/profile to set and verify your email address.", + app.config.domain_name, + )) + })?; + Some(verified_email_address) + } + AuthType::Oidc() => None, + }; + + if let AuthType::Regular(auth) = &auth { + // Use a different rate limit whether this is a new or an existing crate. + let rate_limit_action = match existing_crate { + Some(_) => LimitedAction::PublishUpdate, + None => LimitedAction::PublishNew, + }; + + app.rate_limiter + .check_rate_limit(auth.user().id, rate_limit_action, &mut conn) + .await?; + } let max_upload_size = existing_crate .as_ref() @@ -342,9 +378,6 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult AppResult krate, - None => persist.update(conn).await?, - }; + let krate = match &auth { + AuthType::Regular(auth) => { + let user = auth.user(); - let owners = krate.owners(conn).await?; - if Rights::get(user, &*app.github, &owners).await? < Rights::Publish { - return Err(custom(StatusCode::FORBIDDEN, MISSING_RIGHTS_ERROR_MESSAGE)); - } + // To avoid race conditions, we try to insert + // first so we know whether to add an owner + let krate = match persist.create(conn, user.id).await.optional()? { + Some(krate) => krate, + None => persist.update(conn).await?, + }; + + let owners = krate.owners(conn).await?; + if Rights::get(user, &*app.github, &owners).await? < Rights::Publish { + return Err(custom(StatusCode::FORBIDDEN, MISSING_RIGHTS_ERROR_MESSAGE)); + } + + krate + } + AuthType::Oidc() => { + // OIDC does not support creating new crates + persist.update(conn).await? + } + }; if krate.name != *name { return Err(bad_request(format_args!( @@ -407,6 +452,11 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult Some(auth.user().id), + AuthType::Oidc() => None, + }; + // Read tarball from request let hex_cksum: String = Sha256::digest(&tarball_bytes).encode_hex(); @@ -417,7 +467,7 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult AppResult @@ -441,14 +491,16 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult AppResult> { + let unverified_jwt = json.jwt; + + let unverified_token_data = UnverifiedClaims::decode(&unverified_jwt) + .map_err(|_err| custom(StatusCode::BAD_REQUEST, "Failed to decode JWT"))?; + + let unverified_issuer = unverified_token_data.claims.iss; + let Some(keystore) = state.oidc_key_stores.get(&unverified_issuer) else { + return Err(custom(StatusCode::BAD_REQUEST, "Unsupported JWT issuer")); + }; + + let Some(unverified_key_id) = unverified_token_data.header.kid else { + let message = "Missing JWT key ID"; + return Err(custom(StatusCode::BAD_REQUEST, message)); + }; + + let key = match keystore.get_oidc_key(&unverified_key_id).await { + Ok(Some(key)) => key, + Ok(None) => { + return Err(custom(StatusCode::BAD_REQUEST, "Invalid JWT key ID")); + } + Err(err) => { + warn!("Failed to load OIDC key set: {err}"); + return Err(server_error("Failed to load OIDC key set")); + } + }; + + // 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(custom(StatusCode::BAD_REQUEST, "Unsupported JWT issuer")); + } + + let signed_claims = GitHubClaims::decode(&unverified_jwt, &key).map_err(|err| { + warn!("Failed to decode JWT: {err}"); + custom(StatusCode::BAD_REQUEST, "Failed to decode JWT") + })?; + + let mut conn = state.db_write().await?; + + conn.transaction(|conn| { + async move { + let used_jti = NewUsedJti::new(&signed_claims.jti, signed_claims.exp); + match used_jti.insert(conn).await { + Ok(_) => {} // JTI was successfully inserted, continue + Err(DatabaseError(UniqueViolation, _)) => { + warn!("Attempted JWT reuse (jti: {})", signed_claims.jti); + let detail = "JWT has already been used"; + return Err(custom(StatusCode::BAD_REQUEST, detail)); + } + Err(err) => { + return Err(err.into()); + } + }; + + let repo = &signed_claims.repository; + let Some((repository_owner, repository_name)) = repo.split_once('/') else { + warn!("Unexpected repository format in JWT: {repo}"); + let message = "Unexpected `repository` value"; + return Err(custom(StatusCode::BAD_REQUEST, message)); + }; + + let Some(workflow_filename) = signed_claims.workflow_filename() else { + let job_workflow_ref = &signed_claims.job_workflow_ref; + warn!("Unexpected `job_workflow_ref` format in JWT: {job_workflow_ref}"); + let message = "Unexpected `job_workflow_ref` value"; + return Err(custom(StatusCode::BAD_REQUEST, message)); + }; + + let Ok(repository_owner_id) = signed_claims.repository_owner_id.parse::() else { + let repository_owner_id = &signed_claims.repository_owner_id; + warn!("Unexpected `repository_owner_id` format in JWT: {repository_owner_id}"); + let message = "Unexpected `repository_owner_id` value"; + return Err(custom(StatusCode::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)) + .filter(trustpub_configs_github::repository_owner.eq(&repository_owner)) + .filter(trustpub_configs_github::repository_name.eq(&repository_name)) + .filter(trustpub_configs_github::workflow_filename.eq(&workflow_filename)) + .filter( + trustpub_configs_github::environment + .is_null() + .or(trustpub_configs_github::environment.eq(&signed_claims.environment)), + ) + .load::(conn) + .await?; + + if crate_ids.is_empty() { + warn!("No matching Trusted Publishing config found"); + let message = "No matching Trusted Publishing config found"; + return Err(custom(StatusCode::BAD_REQUEST, message)); + } + + // TODO conflicts with URLs?! + let new_token = SecretString::from(format!( + "crates.io/oidc/{:x}{:x}", + rand::random::(), + rand::random::() + )); + + let hashed_token = Sha256::digest(new_token.expose_secret()); + let expires_at = chrono::Utc::now() + chrono::Duration::minutes(30); + + let new_token_model = NewToken { + expires_at, + hashed_token: hashed_token.as_slice(), + crate_ids: &crate_ids, + }; + + new_token_model.insert(conn).await?; + + let token = new_token.expose_secret().into(); + Ok(Json(json::ExchangeResponse { token })) + } + .scope_boxed() + }) + .await +} + +/// Revoke a temporary access token. +#[utoipa::path( + delete, + path = "/api/v1/oidc_tokens", + tag = "oidc", + responses((status = 204, description = "Successful Response")), +)] +pub async fn revoke_oidc_token(app: AppState, headers: HeaderMap) -> AppResult { + let Some(auth_header) = headers.get(header::AUTHORIZATION) else { + let message = "Missing authorization header"; + return Err(custom(StatusCode::UNAUTHORIZED, message)); + }; + + let Some(bearer) = auth_header.as_bytes().strip_prefix(b"Bearer ") else { + let message = "Invalid authorization header"; + return Err(custom(StatusCode::UNAUTHORIZED, message)); + }; + + if !bearer.starts_with(b"crates.io/oidc/") { + let message = "Invalid authorization header"; + return Err(custom(StatusCode::UNAUTHORIZED, message)); + } + + let hashed_token = Sha256::digest(bearer); + + let mut conn = app.db_write().await?; + + diesel::delete(trustpub_tokens::table) + .filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice())) + .execute(&mut conn) + .await?; + + Ok(StatusCode::NO_CONTENT) +} + +mod json { + use axum::Json; + use axum::extract::FromRequest; + + #[derive(Debug, Deserialize, FromRequest)] + #[from_request(via(Json))] + pub struct ExchangeRequest { + pub jwt: String, + } + + #[derive(Debug, Serialize, utoipa::ToSchema)] + pub struct ExchangeResponse { + pub token: String, + } +} diff --git a/src/router.rs b/src/router.rs index 88ce4c62beb..24e768a04a4 100644 --- a/src/router.rs +++ b/src/router.rs @@ -87,6 +87,12 @@ pub fn build_axum_router(state: AppState) -> Router<()> { .routes(routes!(session::begin_session)) .routes(routes!(session::authorize_session)) .routes(routes!(session::end_session)) + // OIDC / Trusted Publishing + .routes(routes!( + oidc_tokens::exchange_oidc_token, + oidc_tokens::revoke_oidc_token + )) + .routes(routes!(github_oidc_configs::create_github_oidc_config)) .split_for_parts(); let mut router = router diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap.new b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap.new new file mode 100644 index 00000000000..9e543bb4751 --- /dev/null +++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap.new @@ -0,0 +1,4364 @@ +--- +source: src/openapi.rs +assertion_line: 90 +expression: response.json() +--- +{ + "components": { + "schemas": { + "ApiToken": { + "description": "The model representing a row in the `api_tokens` database table.", + "properties": { + "crate_scopes": { + "description": "`None` or a list of crate scope patterns (see RFC #2947).", + "example": [ + "serde" + ], + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "created_at": { + "description": "The date and time when the token was created.", + "example": "2017-01-06T14:23:11Z", + "format": "date-time", + "type": "string" + }, + "endpoint_scopes": { + "description": "A list of endpoint scopes or `None` for the `legacy` endpoint scope (see RFC #2947).", + "example": [ + "publish-update" + ], + "items": { + "$ref": "#/components/schemas/EndpointScope" + }, + "type": [ + "array", + "null" + ] + }, + "expired_at": { + "description": "The date and time when the token will expire, or `null`.", + "example": "2030-10-26T11:32:12Z", + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "An opaque unique identifier for the token.", + "example": 42, + "format": "int32", + "type": "integer" + }, + "last_used_at": { + "description": "The date and time when the token was last used.", + "example": "2021-10-26T11:32:12Z", + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "The name of the token.", + "example": "Example API Token", + "type": "string" + } + }, + "required": [ + "id", + "name", + "created_at" + ], + "type": "object" + }, + "AuthenticatedUser": { + "properties": { + "avatar": { + "description": "The user's avatar URL, if set.", + "example": "https://avatars2.githubusercontent.com/u/1234567?v=4", + "type": [ + "string", + "null" + ] + }, + "email": { + "description": "The user's email address, if set.", + "example": "kate@morgan.dev", + "type": [ + "string", + "null" + ] + }, + "email_verification_sent": { + "description": "Whether the user's email address verification email has been sent.", + "example": true, + "type": "boolean" + }, + "email_verified": { + "description": "Whether the user's email address has been verified.", + "example": true, + "type": "boolean" + }, + "id": { + "description": "An opaque identifier for the user.", + "example": 42, + "format": "int32", + "type": "integer" + }, + "is_admin": { + "description": "Whether the user is a crates.io administrator.", + "example": false, + "type": "boolean" + }, + "login": { + "description": "The user's login name.", + "example": "ghost", + "type": "string" + }, + "name": { + "description": "The user's display name, if set.", + "example": "Kate Morgan", + "type": [ + "string", + "null" + ] + }, + "publish_notifications": { + "description": "Whether the user has opted in to receive publish notifications via email.", + "example": true, + "type": "boolean" + }, + "url": { + "description": "The user's GitHub profile URL.", + "example": "https://github.com/ghost", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "login", + "email_verified", + "email_verification_sent", + "is_admin", + "publish_notifications" + ], + "type": "object" + }, + "Category": { + "properties": { + "category": { + "description": "The name of the category.", + "example": "Game development", + "type": "string" + }, + "crates_cnt": { + "description": "The total number of crates that have this category.", + "example": 42, + "format": "int32", + "type": "integer" + }, + "created_at": { + "description": "The date and time this category was created.", + "example": "2019-12-13T13:46:41Z", + "format": "date-time", + "type": "string" + }, + "description": { + "description": "A description of the category.", + "example": "Libraries for creating games.", + "type": "string" + }, + "id": { + "description": "An opaque identifier for the category.", + "example": "game-development", + "type": "string" + }, + "parent_categories": { + "description": "The parent categories of this category.\n\nThis field is only present when the category details are queried,\nbut not when listing categories.", + "example": [], + "items": { + "$ref": "#/components/schemas/Category" + }, + "type": [ + "array", + "null" + ] + }, + "slug": { + "description": "The \"slug\" of the category.\n\nSee .", + "example": "game-development", + "type": "string" + }, + "subcategories": { + "description": "The subcategories of this category.\n\nThis field is only present when the category details are queried,\nbut not when listing categories.", + "example": [], + "items": { + "$ref": "#/components/schemas/Category" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "id", + "category", + "slug", + "description", + "created_at", + "crates_cnt" + ], + "type": "object" + }, + "Crate": { + "properties": { + "badges": { + "deprecated": true, + "example": [], + "items": { + "type": "object" + }, + "type": "array" + }, + "categories": { + "description": "The list of categories belonging to this crate.", + "example": null, + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "created_at": { + "description": "The date and time this crate was created.", + "example": "2019-12-13T13:46:41Z", + "format": "date-time", + "type": "string" + }, + "default_version": { + "description": "The \"default\" version of this crate.\n\nThis version will be displayed by default on the crate's page.", + "example": "1.3.0", + "type": [ + "string", + "null" + ] + }, + "description": { + "description": "Description of the crate.", + "example": "A generic serialization/deserialization framework", + "type": [ + "string", + "null" + ] + }, + "documentation": { + "description": "The URL to the crate's documentation, if set.", + "example": "https://docs.rs/serde", + "type": [ + "string", + "null" + ] + }, + "downloads": { + "description": "The total number of downloads for this crate.", + "example": 123456789, + "format": "int64", + "type": "integer" + }, + "exact_match": { + "deprecated": true, + "description": "Whether the crate name was an exact match.", + "type": "boolean" + }, + "homepage": { + "description": "The URL to the crate's homepage, if set.", + "example": "https://serde.rs", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "An opaque identifier for the crate.", + "example": "serde", + "type": "string" + }, + "keywords": { + "description": "The list of keywords belonging to this crate.", + "example": null, + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "links": { + "$ref": "#/components/schemas/CrateLinks", + "description": "Links to other API endpoints related to this crate." + }, + "max_stable_version": { + "deprecated": true, + "description": "The highest version number for this crate that is not a pre-release.", + "example": "1.3.0", + "type": [ + "string", + "null" + ] + }, + "max_version": { + "deprecated": true, + "description": "The highest version number for this crate.", + "example": "2.0.0-beta.1", + "type": "string" + }, + "name": { + "description": "The name of the crate.", + "example": "serde", + "type": "string" + }, + "newest_version": { + "deprecated": true, + "description": "The most recently published version for this crate.", + "example": "1.2.3", + "type": "string" + }, + "num_versions": { + "description": "The total number of versions for this crate.", + "example": 13, + "format": "int32", + "type": "integer" + }, + "recent_downloads": { + "description": "The total number of downloads for this crate in the last 90 days.", + "example": 456789, + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "repository": { + "description": "The URL to the crate's repository, if set.", + "example": "https://github.com/serde-rs/serde", + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "description": "The date and time this crate was last updated.", + "example": "2019-12-13T13:46:41Z", + "format": "date-time", + "type": "string" + }, + "versions": { + "description": "The list of version IDs belonging to this crate.", + "example": null, + "items": { + "format": "int32", + "type": "integer" + }, + "type": [ + "array", + "null" + ] + }, + "yanked": { + "description": "Whether all versions of this crate have been yanked.", + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "updated_at", + "badges", + "created_at", + "downloads", + "num_versions", + "yanked", + "max_version", + "newest_version", + "links", + "exact_match" + ], + "type": "object" + }, + "CrateLinks": { + "properties": { + "owner_team": { + "description": "The API path to this crate's team owners.", + "example": "/api/v1/crates/serde/owner_team", + "type": [ + "string", + "null" + ] + }, + "owner_user": { + "description": "The API path to this crate's user owners.", + "example": "/api/v1/crates/serde/owner_user", + "type": [ + "string", + "null" + ] + }, + "owners": { + "description": "The API path to this crate's owners.", + "example": "/api/v1/crates/serde/owners", + "type": [ + "string", + "null" + ] + }, + "reverse_dependencies": { + "description": "The API path to this crate's reverse dependencies.", + "example": "/api/v1/crates/serde/reverse_dependencies", + "type": "string" + }, + "version_downloads": { + "description": "The API path to this crate's download statistics.", + "example": "/api/v1/crates/serde/downloads", + "type": "string" + }, + "versions": { + "description": "The API path to this crate's versions.", + "example": "/api/v1/crates/serde/versions", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "version_downloads", + "reverse_dependencies" + ], + "type": "object" + }, + "CrateOwnerInvitation": { + "properties": { + "crate_id": { + "description": "The ID of the crate that the user was invited to be an owner of.", + "example": 123, + "format": "int32", + "type": "integer" + }, + "crate_name": { + "description": "The name of the crate that the user was invited to be an owner of.", + "example": "serde", + "type": "string" + }, + "created_at": { + "description": "The date and time this invitation was created.", + "example": "2019-12-13T13:46:41Z", + "format": "date-time", + "type": "string" + }, + "expires_at": { + "description": "The date and time this invitation will expire.", + "example": "2020-01-13T13:46:41Z", + "format": "date-time", + "type": "string" + }, + "invitee_id": { + "description": "The ID of the user who was invited to be a crate owner.", + "example": 42, + "format": "int32", + "type": "integer" + }, + "inviter_id": { + "description": "The ID of the user who sent the invitation.", + "example": 3, + "format": "int32", + "type": "integer" + } + }, + "required": [ + "invitee_id", + "inviter_id", + "crate_id", + "crate_name", + "created_at", + "expires_at" + ], + "type": "object" + }, + "EncodableApiTokenWithToken": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiToken" + }, + { + "properties": { + "token": { + "description": "The plaintext API token.\n\nOnly available when the token is created.", + "example": "a1b2c3d4e5f6g7h8i9j0", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + } + ] + }, + "EncodableDependency": { + "properties": { + "crate_id": { + "description": "The name of the crate this dependency points to.", + "example": "serde", + "type": "string" + }, + "default_features": { + "description": "Whether default features are enabled for this dependency.", + "example": true, + "type": "boolean" + }, + "downloads": { + "description": "The total number of downloads for the crate this dependency points to.", + "example": 123456, + "format": "int64", + "type": "integer" + }, + "features": { + "description": "The features explicitly enabled for this dependency.", + "items": { + "type": "string" + }, + "type": "array" + }, + "id": { + "description": "An opaque identifier for the dependency.", + "example": 169, + "format": "int32", + "type": "integer" + }, + "kind": { + "description": "The type of dependency this is (normal, dev, or build).", + "example": "normal", + "type": "string" + }, + "optional": { + "description": "Whether this dependency is optional.", + "type": "boolean" + }, + "req": { + "description": "The version requirement for this dependency.", + "example": "^1", + "type": "string" + }, + "target": { + "description": "The target platform for this dependency, if any.", + "type": [ + "string", + "null" + ] + }, + "version_id": { + "description": "The ID of the version this dependency belongs to.", + "example": 42, + "format": "int32", + "type": "integer" + } + }, + "required": [ + "id", + "version_id", + "crate_id", + "req", + "optional", + "default_features", + "features", + "kind", + "downloads" + ], + "type": "object" + }, + "EndpointScope": { + "enum": [ + "publish-new", + "publish-update", + "yank", + "change-owners" + ], + "type": "string" + }, + "GitHubOidcConfig": { + "properties": { + "crate": { + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "environment": { + "type": [ + "string", + "null" + ] + }, + "id": { + "format": "int32", + "type": "integer" + }, + "repository_name": { + "type": "string" + }, + "repository_owner": { + "type": "string" + }, + "repository_owner_id": { + "format": "int32", + "type": "integer" + }, + "workflow_filename": { + "type": "string" + } + }, + "required": [ + "id", + "crate", + "repository_owner", + "repository_owner_id", + "repository_name", + "workflow_filename", + "created_at" + ], + "type": "object" + }, + "Keyword": { + "properties": { + "crates_cnt": { + "description": "The total number of crates that have this keyword.", + "example": 42, + "format": "int32", + "type": "integer" + }, + "created_at": { + "description": "The date and time this keyword was created.", + "example": "2017-01-06T14:23:11Z", + "format": "date-time", + "type": "string" + }, + "id": { + "description": "An opaque identifier for the keyword.", + "example": "http", + "type": "string" + }, + "keyword": { + "description": "The keyword itself.", + "example": "http", + "type": "string" + } + }, + "required": [ + "id", + "keyword", + "created_at", + "crates_cnt" + ], + "type": "object" + }, + "LegacyCrateOwnerInvitation": { + "properties": { + "crate_id": { + "description": "The ID of the crate that the user was invited to be an owner of.", + "example": 123, + "format": "int32", + "type": "integer" + }, + "crate_name": { + "description": "The name of the crate that the user was invited to be an owner of.", + "example": "serde", + "type": "string" + }, + "created_at": { + "description": "The date and time this invitation was created.", + "example": "2019-12-13T13:46:41Z", + "format": "date-time", + "type": "string" + }, + "expires_at": { + "description": "The date and time this invitation will expire.", + "example": "2020-01-13T13:46:41Z", + "format": "date-time", + "type": "string" + }, + "invited_by_username": { + "description": "The username of the user who sent the invitation.", + "example": "ghost", + "type": "string" + }, + "invitee_id": { + "description": "The ID of the user who was invited to be a crate owner.", + "example": 42, + "format": "int32", + "type": "integer" + }, + "inviter_id": { + "description": "The ID of the user who sent the invitation.", + "example": 3, + "format": "int32", + "type": "integer" + } + }, + "required": [ + "invitee_id", + "inviter_id", + "invited_by_username", + "crate_name", + "crate_id", + "created_at", + "expires_at" + ], + "type": "object" + }, + "Owner": { + "properties": { + "avatar": { + "description": "The avatar URL of the team or user.", + "example": "https://avatars2.githubusercontent.com/u/1234567?v=4", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "The opaque identifier for the team or user, depending on the `kind` field.", + "example": 42, + "format": "int32", + "type": "integer" + }, + "kind": { + "description": "The kind of the owner (`user` or `team`).", + "example": "user", + "type": "string" + }, + "login": { + "description": "The login name of the team or user.", + "example": "ghost", + "type": "string" + }, + "name": { + "description": "The display name of the team or user.", + "example": "Kate Morgan", + "type": [ + "string", + "null" + ] + }, + "url": { + "description": "The URL to the owner's profile.", + "example": "https://github.com/ghost", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "login", + "kind" + ], + "type": "object" + }, + "PublishWarnings": { + "properties": { + "invalid_badges": { + "deprecated": true, + "example": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "invalid_categories": { + "example": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "other": { + "example": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "invalid_categories", + "invalid_badges", + "other" + ], + "type": "object" + }, + "Slug": { + "properties": { + "description": { + "description": "A description of the category.", + "example": "Libraries for creating games.", + "type": "string" + }, + "id": { + "description": "An opaque identifier for the category.", + "example": "game-development", + "type": "string" + }, + "slug": { + "description": "The \"slug\" of the category.\n\nSee .", + "example": "game-development", + "type": "string" + } + }, + "required": [ + "id", + "slug", + "description" + ], + "type": "object" + }, + "Team": { + "properties": { + "avatar": { + "description": "The avatar URL of the team.", + "example": "https://avatars2.githubusercontent.com/u/1234567?v=4", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "An opaque identifier for the team.", + "example": 42, + "format": "int32", + "type": "integer" + }, + "login": { + "description": "The login name of the team.", + "example": "github:rust-lang:crates-io", + "type": "string" + }, + "name": { + "description": "The display name of the team.", + "example": "Crates.io team", + "type": [ + "string", + "null" + ] + }, + "url": { + "description": "The GitHub profile URL of the team.", + "example": "https://github.com/rust-lang", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "login" + ], + "type": "object" + }, + "User": { + "properties": { + "avatar": { + "description": "The user's avatar URL, if set.", + "example": "https://avatars2.githubusercontent.com/u/1234567?v=4", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "An opaque identifier for the user.", + "example": 42, + "format": "int32", + "type": "integer" + }, + "login": { + "description": "The user's login name.", + "example": "ghost", + "type": "string" + }, + "name": { + "description": "The user's display name, if set.", + "example": "Kate Morgan", + "type": [ + "string", + "null" + ] + }, + "url": { + "description": "The user's GitHub profile URL.", + "example": "https://github.com/ghost", + "type": "string" + } + }, + "required": [ + "id", + "login", + "url" + ], + "type": "object" + }, + "Version": { + "properties": { + "audit_actions": { + "description": "A list of actions performed on this version.", + "items": { + "properties": { + "action": { + "description": "The action that was performed.", + "example": "publish", + "type": "string" + }, + "time": { + "description": "The date and time the action was performed.", + "example": "2019-12-13T13:46:41Z", + "format": "date-time", + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/User", + "description": "The user who performed the action." + } + }, + "required": [ + "action", + "user", + "time" + ], + "type": "object" + }, + "type": "array" + }, + "bin_names": { + "description": "The names of the binaries provided by this version, if any.", + "example": [], + "items": { + "type": [ + "string", + "null" + ] + }, + "type": [ + "array", + "null" + ] + }, + "checksum": { + "description": "The SHA256 checksum of the compressed crate file encoded as a\nhexadecimal string.", + "example": "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60", + "type": "string" + }, + "crate": { + "description": "The name of the crate.", + "example": "serde", + "type": "string" + }, + "crate_size": { + "description": "The size of the compressed crate file in bytes.", + "example": 1234, + "format": "int32", + "type": "integer" + }, + "created_at": { + "description": "The date and time this version was created.", + "example": "2019-12-13T13:46:41Z", + "format": "date-time", + "type": "string" + }, + "description": { + "description": "The description of this version of the crate.", + "example": "A generic serialization/deserialization framework", + "type": [ + "string", + "null" + ] + }, + "dl_path": { + "description": "The API path to download the crate.", + "example": "/api/v1/crates/serde/1.0.0/download", + "type": "string" + }, + "documentation": { + "description": "The URL to the crate's documentation, if set.", + "example": "https://docs.rs/serde", + "type": [ + "string", + "null" + ] + }, + "downloads": { + "description": "The total number of downloads for this version.", + "example": 123456, + "format": "int32", + "type": "integer" + }, + "edition": { + "description": "The Rust Edition used to compile this version, if set.", + "example": "2021", + "type": [ + "string", + "null" + ] + }, + "features": { + "description": "The features defined by this version.", + "type": "object" + }, + "has_lib": { + "description": "Whether this version can be used as a library.", + "example": true, + "type": [ + "boolean", + "null" + ] + }, + "homepage": { + "description": "The URL to the crate's homepage, if set.", + "example": "https://serde.rs", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "An opaque identifier for the version.", + "example": 42, + "format": "int32", + "type": "integer" + }, + "lib_links": { + "description": "The name of the native library this version links with, if any.", + "example": "git2", + "type": [ + "string", + "null" + ] + }, + "license": { + "description": "The license of this version of the crate.", + "example": "MIT", + "type": [ + "string", + "null" + ] + }, + "links": { + "$ref": "#/components/schemas/VersionLinks", + "description": "Links to other API endpoints related to this version." + }, + "num": { + "description": "The version number.", + "example": "1.0.0", + "type": "string" + }, + "published_by": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/User", + "description": "The user who published this version.\n\nThis field may be `null` if the version was published before crates.io\nstarted recording this information." + } + ] + }, + "readme_path": { + "description": "The API path to download the crate's README file as HTML code.", + "example": "/api/v1/crates/serde/1.0.0/readme", + "type": "string" + }, + "repository": { + "description": "The URL to the crate's repository, if set.", + "example": "https://github.com/serde-rs/serde", + "type": [ + "string", + "null" + ] + }, + "rust_version": { + "description": "The minimum version of the Rust compiler required to compile\nthis version, if set.", + "example": "1.31", + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "description": "The date and time this version was last updated (i.e. yanked or unyanked).", + "example": "2019-12-13T13:46:41Z", + "format": "date-time", + "type": "string" + }, + "yank_message": { + "description": "The message given when this version was yanked, if any.", + "example": "Security vulnerability", + "type": [ + "string", + "null" + ] + }, + "yanked": { + "description": "Whether this version has been yanked.", + "example": false, + "type": "boolean" + } + }, + "required": [ + "id", + "crate", + "num", + "dl_path", + "readme_path", + "updated_at", + "created_at", + "downloads", + "features", + "yanked", + "links", + "crate_size", + "audit_actions", + "checksum" + ], + "type": "object" + }, + "VersionDownload": { + "properties": { + "date": { + "description": "The date this download count is for.", + "example": "2019-12-13", + "type": "string" + }, + "downloads": { + "description": "The number of downloads for this version on the given date.", + "example": 123, + "format": "int32", + "type": "integer" + }, + "version": { + "description": "The ID of the version this download count is for.", + "example": 42, + "format": "int32", + "type": "integer" + } + }, + "required": [ + "version", + "downloads", + "date" + ], + "type": "object" + }, + "VersionLinks": { + "properties": { + "authors": { + "deprecated": true, + "description": "The API path to download this version's authors.", + "example": "/api/v1/crates/serde/1.0.0/authors", + "type": "string" + }, + "dependencies": { + "description": "The API path to download this version's dependencies.", + "example": "/api/v1/crates/serde/1.0.0/dependencies", + "type": "string" + }, + "version_downloads": { + "description": "The API path to download this version's download numbers.", + "example": "/api/v1/crates/serde/1.0.0/downloads", + "type": "string" + } + }, + "required": [ + "dependencies", + "version_downloads", + "authors" + ], + "type": "object" + } + }, + "securitySchemes": { + "api_token": { + "description": "The API token is used to authenticate requests from cargo and other clients.", + "in": "header", + "name": "authorization", + "type": "apiKey" + }, + "cookie": { + "description": "The session cookie is used by the web UI to authenticate users.", + "in": "cookie", + "name": "cargo_session", + "type": "apiKey" + } + } + }, + "info": { + "contact": { + "email": "help@crates.io", + "name": "the crates.io team" + }, + "description": "\n__Experimental API documentation for the [crates.io](https://crates.io/)\npackage registry.__\n\nThis document describes the API used by the crates.io website, cargo\nclient, and other third-party tools to interact with the crates.io\nregistry.\n\nBefore using this API, please read the\n[crates.io data access policy](https://crates.io/data-access) and ensure\nthat your use of the API complies with the policy.\n\n__The API is under active development and may change at any time__,\nthough we will try to avoid breaking changes where possible.\n\nSome parts of the API follow the \"Registry Web API\" spec documented\nat \nand can be considered stable.\n\nMost parts of the API do not require authentication. The endpoints\nthat do require authentication are marked as such in the documentation,\nwith some requiring cookie authentication (usable only by the web UI)\nand others requiring API token authentication (usable by cargo and\nother clients).\n", + "license": { + "name": "MIT OR Apache-2.0", + "url": "https://github.com/rust-lang/crates.io/blob/main/README.md#%EF%B8%8F-license" + }, + "termsOfService": "https://crates.io/policies", + "title": "crates.io", + "version": "0.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/api/private/crate_owner_invitations": { + "get": { + "operationId": "list_crate_owner_invitations", + "parameters": [ + { + "description": "Filter crate owner invitations by crate name.\n\nOnly crate owners can query pending invitations for their crate.", + "in": "query", + "name": "crate_name", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "The ID of the user who was invited to be a crate owner.\n\nThis parameter needs to match the authenticated user's ID.", + "in": "query", + "name": "invitee_id", + "required": false, + "schema": { + "format": "int32", + "type": "integer" + } + }, + { + "description": "The page number to request.\n\nThis parameter is mutually exclusive with `seek` and not supported for\nall requests.", + "in": "query", + "name": "page", + "required": false, + "schema": { + "format": "int32", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "The number of items to request per page.", + "in": "query", + "name": "per_page", + "required": false, + "schema": { + "format": "int32", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "The seek key to request.\n\nThis parameter is mutually exclusive with `page` and not supported for\nall requests.\n\nThe seek key can usually be found in the `meta.next_page` field of\npaginated responses.", + "in": "query", + "name": "seek", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "invitations": { + "description": "The list of crate owner invitations.", + "items": { + "$ref": "#/components/schemas/CrateOwnerInvitation" + }, + "type": "array" + }, + "meta": { + "properties": { + "next_page": { + "description": "Query parameter string to fetch the next page of results.", + "example": "?seek=c0ffee", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "users": { + "description": "The list of users referenced in the crate owner invitations.", + "items": { + "$ref": "#/components/schemas/User" + }, + "type": "array" + } + }, + "required": [ + "invitations", + "users", + "meta" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "cookie": [] + } + ], + "summary": "List all crate owner invitations for a crate or user.", + "tags": [ + "owners" + ] + } + }, + "/api/private/session": { + "delete": { + "operationId": "end_session", + "responses": { + "200": { + "description": "Successful Response" + } + }, + "security": [ + { + "cookie": [] + } + ], + "summary": "End the current session.", + "tags": [ + "session" + ] + } + }, + "/api/private/session/authorize": { + "get": { + "description": "This route is called from the GitHub API OAuth flow after the user accepted or rejected\nthe data access permissions. It will check the `state` parameter and then call the GitHub API\nto exchange the temporary `code` for an API token. The API token is returned together with\nthe corresponding user information.\n\nsee \n\n## Query Parameters\n\n- `code` – temporary code received from the GitHub API **(Required)**\n- `state` – state parameter received from the GitHub API **(Required)**", + "operationId": "authorize_session", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "owned_crates": { + "description": "The crates that the authenticated user owns.", + "items": { + "properties": { + "email_notifications": { + "deprecated": true, + "type": "boolean" + }, + "id": { + "description": "The opaque identifier of the crate.", + "example": 123, + "format": "int32", + "type": "integer" + }, + "name": { + "description": "The name of the crate.", + "example": "serde", + "type": "string" + } + }, + "required": [ + "id", + "name", + "email_notifications" + ], + "type": "object" + }, + "type": "array" + }, + "user": { + "$ref": "#/components/schemas/AuthenticatedUser", + "description": "The authenticated user." + } + }, + "required": [ + "user", + "owned_crates" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Complete authentication flow.", + "tags": [ + "session" + ] + } + }, + "/api/private/session/begin": { + "get": { + "description": "This route will return an authorization URL for the GitHub OAuth flow including the crates.io\n`client_id` and a randomly generated `state` secret.\n\nsee ", + "operationId": "begin_session", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "state": { + "example": "b84a63c4ea3fcb4ac84", + "type": "string" + }, + "url": { + "example": "https://github.com/login/oauth/authorize?client_id=...&state=...&scope=read%3Aorg", + "type": "string" + } + }, + "required": [ + "url", + "state" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Begin authentication flow.", + "tags": [ + "session" + ] + } + }, + "/api/v1/categories": { + "get": { + "operationId": "list_categories", + "parameters": [ + { + "description": "The sort order of the categories.\n\nValid values: `alpha`, and `crates`.\n\nDefaults to `alpha`.", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "The page number to request.\n\nThis parameter is mutually exclusive with `seek` and not supported for\nall requests.", + "in": "query", + "name": "page", + "required": false, + "schema": { + "format": "int32", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "The number of items to request per page.", + "in": "query", + "name": "per_page", + "required": false, + "schema": { + "format": "int32", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "The seek key to request.\n\nThis parameter is mutually exclusive with `page` and not supported for\nall requests.\n\nThe seek key can usually be found in the `meta.next_page` field of\npaginated responses.", + "in": "query", + "name": "seek", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "categories": { + "description": "The list of categories.", + "items": { + "$ref": "#/components/schemas/Category" + }, + "type": "array" + }, + "meta": { + "properties": { + "total": { + "description": "The total number of categories.", + "example": 123, + "format": "int64", + "type": "integer" + } + }, + "required": [ + "total" + ], + "type": "object" + } + }, + "required": [ + "categories", + "meta" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List all categories.", + "tags": [ + "categories" + ] + } + }, + "/api/v1/categories/{category}": { + "get": { + "operationId": "find_category", + "parameters": [ + { + "description": "Name of the category", + "in": "path", + "name": "category", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "category": { + "$ref": "#/components/schemas/Category" + } + }, + "required": [ + "category" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get category metadata.", + "tags": [ + "categories" + ] + } + }, + "/api/v1/category_slugs": { + "get": { + "operationId": "list_category_slugs", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "category_slugs": { + "description": "The list of category slugs.", + "items": { + "$ref": "#/components/schemas/Slug" + }, + "type": "array" + } + }, + "required": [ + "category_slugs" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List all available category slugs.", + "tags": [ + "categories" + ] + } + }, + "/api/v1/confirm/{email_token}": { + "put": { + "operationId": "confirm_user_email", + "parameters": [ + { + "description": "Secret verification token sent to the user's email address", + "in": "path", + "name": "email_token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "example": true, + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Marks the email belonging to the given token as verified.", + "tags": [ + "users" + ] + } + }, + "/api/v1/crates": { + "get": { + "description": "Called in a variety of scenarios in the front end, including:\n- Alphabetical listing of crates\n- List of crates under a specific owner\n- Listing a user's followed crates", + "operationId": "list_crates", + "parameters": [ + { + "description": "The sort order of the crates.\n\nValid values: `alphabetical`, `relevance`, `downloads`,\n`recent-downloads`, `recent-updates`, `new`.\n\nDefaults to `relevance` if `q` is set, otherwise `alphabetical`.", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "A search query string.", + "in": "query", + "name": "q", + "required": false, + "schema": { + "description": "A string that does not contain null bytes (`\\0`).", + "type": "string" + } + }, + { + "description": "Set to `yes` to include yanked crates.", + "example": "yes", + "in": "query", + "name": "include_yanked", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "If set, only return crates that belong to this category, or one\nof its subcategories.", + "in": "query", + "name": "category", + "required": false, + "schema": { + "description": "A string that does not contain null bytes (`\\0`).", + "type": "string" + } + }, + { + "description": "If set, only return crates matching all the given keywords.\n\nThis parameter expects a space-separated list of keywords.", + "in": "query", + "name": "all_keywords", + "required": false, + "schema": { + "description": "A string that does not contain null bytes (`\\0`).", + "type": "string" + } + }, + { + "description": "If set, only return crates matching the given keyword\n(ignored if `all_keywords` is set).", + "in": "query", + "name": "keyword", + "required": false, + "schema": { + "description": "A string that does not contain null bytes (`\\0`).", + "type": "string" + } + }, + { + "description": "If set, only return crates with names that start with the given letter\n(ignored if `all_keywords` or `keyword` are set).", + "in": "query", + "name": "letter", + "required": false, + "schema": { + "description": "A string that does not contain null bytes (`\\0`).", + "type": "string" + } + }, + { + "description": "If set, only crates owned by the given crates.io user ID are returned\n(ignored if `all_keywords`, `keyword`, or `letter` are set).", + "in": "query", + "name": "user_id", + "required": false, + "schema": { + "format": "int32", + "type": "integer" + } + }, + { + "description": "If set, only crates owned by the given crates.io team ID are returned\n(ignored if `all_keywords`, `keyword`, `letter`, or `user_id` are set).", + "in": "query", + "name": "team_id", + "required": false, + "schema": { + "format": "int32", + "type": "integer" + } + }, + { + "description": "If set, only crates owned by users the current user follows are returned\n(ignored if `all_keywords`, `keyword`, `letter`, `user_id`,\nor `team_id` are set).\n\nThe exact value of this parameter is ignored, but it must not be empty.", + "example": "yes", + "in": "query", + "name": "following", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "If set, only crates with the specified names are returned (ignored\nif `all_keywords`, `keyword`, `letter`, `user_id`, `team_id`,\nor `following` are set).", + "in": "query", + "name": "ids[]", + "required": false, + "schema": { + "items": { + "description": "A string that does not contain null bytes (`\\0`).", + "type": "string" + }, + "type": "array" + } + }, + { + "description": "The page number to request.\n\nThis parameter is mutually exclusive with `seek` and not supported for\nall requests.", + "in": "query", + "name": "page", + "required": false, + "schema": { + "format": "int32", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "The number of items to request per page.", + "in": "query", + "name": "per_page", + "required": false, + "schema": { + "format": "int32", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "The seek key to request.\n\nThis parameter is mutually exclusive with `page` and not supported for\nall requests.\n\nThe seek key can usually be found in the `meta.next_page` field of\npaginated responses.", + "in": "query", + "name": "seek", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "crates": { + "items": { + "$ref": "#/components/schemas/Crate" + }, + "type": "array" + }, + "meta": { + "properties": { + "next_page": { + "description": "Query string to the next page of results, if any.", + "example": "?page=3", + "type": [ + "string", + "null" + ] + }, + "prev_page": { + "description": "Query string to the previous page of results, if any.", + "example": "?page=1", + "type": [ + "string", + "null" + ] + }, + "total": { + "description": "The total number of crates that match the query.", + "example": 123, + "format": "int64", + "type": "integer" + } + }, + "required": [ + "total" + ], + "type": "object" + } + }, + "required": [ + "crates", + "meta" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + {}, + { + "api_token": [] + }, + { + "cookie": [] + } + ], + "summary": "Returns a list of crates.", + "tags": [ + "crates" + ] + } + }, + "/api/v1/crates/new": { + "get": { + "description": "This endpoint works around a small limitation in `axum` and is delegating\nto the `GET /api/v1/crates/{name}` endpoint internally.", + "operationId": "find_new_crate", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "categories": { + "description": "The categories of the crate.", + "example": null, + "items": { + "$ref": "#/components/schemas/Category" + }, + "type": [ + "array", + "null" + ] + }, + "crate": { + "$ref": "#/components/schemas/Crate", + "description": "The crate metadata." + }, + "keywords": { + "description": "The keywords of the crate.", + "example": null, + "items": { + "$ref": "#/components/schemas/Keyword" + }, + "type": [ + "array", + "null" + ] + }, + "versions": { + "description": "The versions of the crate.", + "example": null, + "items": { + "$ref": "#/components/schemas/Version" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "crate" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get crate metadata (for the `new` crate).", + "tags": [ + "crates" + ] + }, + "put": { + "description": "Used by `cargo publish` to publish a new crate or to publish a new version of an\nexisting crate.", + "operationId": "publish", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "crate": { + "$ref": "#/components/schemas/Crate" + }, + "warnings": { + "$ref": "#/components/schemas/PublishWarnings" + } + }, + "required": [ + "crate", + "warnings" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "api_token": [] + }, + { + "cookie": [] + } + ], + "summary": "Publish a new crate/version.", + "tags": [ + "publish" + ] + } + }, + "/api/v1/crates/{name}": { + "delete": { + "description": "The crate is immediately deleted from the database, and with a small delay\nfrom the git and sparse index, and the crate file storage.\n\nThe crate can only be deleted by the owner of the crate, and only if the\ncrate has been published for less than 72 hours, or if the crate has a\nsingle owner, has been downloaded less than 500 times for each month it has\nbeen published, and is not depended upon by any other crate on crates.io.", + "operationId": "delete_crate", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "message", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + } + }, + "security": [ + { + "cookie": [] + } + ], + "summary": "Delete a crate.", + "tags": [ + "crates" + ] + }, + "get": { + "operationId": "find_crate", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Additional data to include in the response.\n\nValid values: `versions`, `keywords`, `categories`, `badges`,\n`downloads`, `default_version`, or `full`.\n\nDefaults to `full` for backwards compatibility.\n\n**Note**: `versions` and `default_version` share the same key `versions`, therefore `default_version` will be ignored if both are provided.\n\nThis parameter expects a comma-separated list of values.", + "in": "query", + "name": "include", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "categories": { + "description": "The categories of the crate.", + "example": null, + "items": { + "$ref": "#/components/schemas/Category" + }, + "type": [ + "array", + "null" + ] + }, + "crate": { + "$ref": "#/components/schemas/Crate", + "description": "The crate metadata." + }, + "keywords": { + "description": "The keywords of the crate.", + "example": null, + "items": { + "$ref": "#/components/schemas/Keyword" + }, + "type": [ + "array", + "null" + ] + }, + "versions": { + "description": "The versions of the crate.", + "example": null, + "items": { + "$ref": "#/components/schemas/Version" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "crate" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get crate metadata.", + "tags": [ + "crates" + ] + } + }, + "/api/v1/crates/{name}/downloads": { + "get": { + "description": "This includes the per-day downloads for the last 90 days and for the\nlatest 5 versions plus the sum of the rest.", + "operationId": "get_crate_downloads", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Additional data to include in the response.\n\nValid values: `versions`.\n\nDefaults to no additional data.\n\nThis parameter expects a comma-separated list of values.", + "in": "query", + "name": "include", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "meta": { + "properties": { + "extra_downloads": { + "items": { + "properties": { + "date": { + "description": "The date this download count is for.", + "example": "2019-12-13", + "type": "string" + }, + "downloads": { + "description": "The number of downloads on the given date.", + "example": 123, + "format": "int64", + "type": "integer" + } + }, + "required": [ + "date", + "downloads" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "extra_downloads" + ], + "type": "object" + }, + "version_downloads": { + "description": "The per-day download counts for the last 90 days.", + "items": { + "$ref": "#/components/schemas/VersionDownload" + }, + "type": "array" + }, + "versions": { + "description": "The versions referenced in the download counts, if `?include=versions`\nwas requested.", + "items": { + "$ref": "#/components/schemas/Version" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "version_downloads", + "meta" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get the download counts for a crate.", + "tags": [ + "crates" + ] + } + }, + "/api/v1/crates/{name}/follow": { + "delete": { + "operationId": "unfollow_crate", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "example": true, + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "api_token": [] + }, + { + "cookie": [] + } + ], + "summary": "Unfollow a crate.", + "tags": [ + "crates" + ] + }, + "put": { + "operationId": "follow_crate", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "example": true, + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "api_token": [] + }, + { + "cookie": [] + } + ], + "summary": "Follow a crate.", + "tags": [ + "crates" + ] + } + }, + "/api/v1/crates/{name}/following": { + "get": { + "operationId": "get_following_crate", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "following": { + "description": "Whether the authenticated user is following the crate.", + "type": "boolean" + } + }, + "required": [ + "following" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "cookie": [] + } + ], + "summary": "Check if a crate is followed.", + "tags": [ + "crates" + ] + } + }, + "/api/v1/crates/{name}/owner_team": { + "get": { + "operationId": "get_team_owners", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "teams": { + "items": { + "$ref": "#/components/schemas/Owner" + }, + "type": "array" + } + }, + "required": [ + "teams" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List team owners of a crate.", + "tags": [ + "owners" + ] + } + }, + "/api/v1/crates/{name}/owner_user": { + "get": { + "operationId": "get_user_owners", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "users": { + "items": { + "$ref": "#/components/schemas/Owner" + }, + "type": "array" + } + }, + "required": [ + "users" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List user owners of a crate.", + "tags": [ + "owners" + ] + } + }, + "/api/v1/crates/{name}/owners": { + "delete": { + "operationId": "remove_owners", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "msg": { + "description": "A message describing the result of the operation.", + "example": "user ghost has been invited to be an owner of crate serde", + "type": "string" + }, + "ok": { + "example": true, + "type": "boolean" + } + }, + "required": [ + "msg", + "ok" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "api_token": [] + }, + { + "cookie": [] + } + ], + "summary": "Remove crate owners.", + "tags": [ + "owners" + ] + }, + "get": { + "operationId": "list_owners", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "users": { + "items": { + "$ref": "#/components/schemas/Owner" + }, + "type": "array" + } + }, + "required": [ + "users" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List crate owners.", + "tags": [ + "owners" + ] + }, + "put": { + "operationId": "add_owners", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "msg": { + "description": "A message describing the result of the operation.", + "example": "user ghost has been invited to be an owner of crate serde", + "type": "string" + }, + "ok": { + "example": true, + "type": "boolean" + } + }, + "required": [ + "msg", + "ok" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "api_token": [] + }, + { + "cookie": [] + } + ], + "summary": "Add crate owners.", + "tags": [ + "owners" + ] + } + }, + "/api/v1/crates/{name}/reverse_dependencies": { + "get": { + "operationId": "list_reverse_dependencies", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "dependencies": { + "description": "The list of reverse dependencies of the crate.", + "items": { + "$ref": "#/components/schemas/EncodableDependency" + }, + "type": "array" + }, + "meta": { + "properties": { + "total": { + "example": 32, + "format": "int64", + "type": "integer" + } + }, + "required": [ + "total" + ], + "type": "object" + }, + "versions": { + "description": "The versions referenced in the `dependencies` field.", + "items": { + "$ref": "#/components/schemas/Version" + }, + "type": "array" + } + }, + "required": [ + "dependencies", + "versions", + "meta" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List reverse dependencies of a crate.", + "tags": [ + "crates" + ] + } + }, + "/api/v1/crates/{name}/versions": { + "get": { + "operationId": "list_versions", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Additional data to include in the response.\n\nValid values: `release_tracks`.\n\nDefaults to no additional data.\n\nThis parameter expects a comma-separated list of values.", + "in": "query", + "name": "include", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "The sort order of the versions.\n\nValid values: `date`, and `semver`.\n\nDefaults to `semver`.", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "If set, only versions with the specified semver strings are returned.", + "in": "query", + "name": "nums[]", + "required": false, + "schema": { + "items": { + "description": "A string that does not contain null bytes (`\\0`).", + "type": "string" + }, + "type": "array" + } + }, + { + "description": "The page number to request.\n\nThis parameter is mutually exclusive with `seek` and not supported for\nall requests.", + "in": "query", + "name": "page", + "required": false, + "schema": { + "format": "int32", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "The number of items to request per page.", + "in": "query", + "name": "per_page", + "required": false, + "schema": { + "format": "int32", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "The seek key to request.\n\nThis parameter is mutually exclusive with `page` and not supported for\nall requests.\n\nThe seek key can usually be found in the `meta.next_page` field of\npaginated responses.", + "in": "query", + "name": "seek", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "meta": { + "properties": { + "next_page": { + "description": "Query string to the next page of results, if any.", + "example": "?page=3", + "type": [ + "string", + "null" + ] + }, + "release_tracks": { + "description": "Additional data about the crate's release tracks,\nif `?include=release_tracks` is used.", + "type": [ + "object", + "null" + ] + }, + "total": { + "description": "The total number of versions belonging to the crate.", + "example": 123, + "format": "int64", + "type": "integer" + } + }, + "required": [ + "total" + ], + "type": "object" + }, + "versions": { + "items": { + "$ref": "#/components/schemas/Version" + }, + "type": "array" + } + }, + "required": [ + "versions", + "meta" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List all versions of a crate.", + "tags": [ + "versions" + ] + } + }, + "/api/v1/crates/{name}/{version}": { + "get": { + "operationId": "find_version", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Version number", + "example": "1.0.0", + "in": "path", + "name": "version", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "version": { + "$ref": "#/components/schemas/Version" + } + }, + "required": [ + "version" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get crate version metadata.", + "tags": [ + "versions" + ] + }, + "patch": { + "description": "This endpoint allows updating the `yanked` state of a version, including a yank message.", + "operationId": "update_version", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Version number", + "example": "1.0.0", + "in": "path", + "name": "version", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "version": { + "$ref": "#/components/schemas/Version" + } + }, + "required": [ + "version" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "api_token": [] + }, + { + "cookie": [] + } + ], + "summary": "Update a crate version.", + "tags": [ + "versions" + ] + } + }, + "/api/v1/crates/{name}/{version}/authors": { + "get": { + "deprecated": true, + "description": "This endpoint was deprecated by [RFC #3052](https://github.com/rust-lang/rfcs/pull/3052)\nand returns an empty list for backwards compatibility reasons.", + "operationId": "get_version_authors", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Version number", + "example": "1.0.0", + "in": "path", + "name": "version", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful Response" + } + }, + "summary": "Get crate version authors.", + "tags": [ + "versions" + ] + } + }, + "/api/v1/crates/{name}/{version}/dependencies": { + "get": { + "description": "This information can also be obtained directly from the index.\n\nIn addition to returning cached data from the index, this returns\nfields for `id`, `version_id`, and `downloads` (which appears to always\nbe 0)", + "operationId": "get_version_dependencies", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Version number", + "example": "1.0.0", + "in": "path", + "name": "version", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "dependencies": { + "items": { + "$ref": "#/components/schemas/EncodableDependency" + }, + "type": "array" + } + }, + "required": [ + "dependencies" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get crate version dependencies.", + "tags": [ + "versions" + ] + } + }, + "/api/v1/crates/{name}/{version}/download": { + "get": { + "description": "This returns a URL to the location where the crate is stored.", + "operationId": "download_version", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Version number", + "example": "1.0.0", + "in": "path", + "name": "version", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "url": { + "description": "The URL to the crate file.", + "example": "https://static.crates.io/crates/serde/serde-1.0.0.crate", + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object" + } + } + }, + "description": "Successful Response (for `content-type: application/json`)" + }, + "302": { + "description": "Successful Response (default)", + "headers": { + "location": { + "description": "The URL to the crate file.", + "schema": { + "type": "string" + } + } + } + } + }, + "summary": "Download a crate version.", + "tags": [ + "versions" + ] + } + }, + "/api/v1/crates/{name}/{version}/downloads": { + "get": { + "description": "This includes the per-day downloads for the last 90 days.", + "operationId": "get_version_downloads", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Version number", + "example": "1.0.0", + "in": "path", + "name": "version", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Only return download counts before this date.", + "example": "2024-06-28", + "in": "query", + "name": "before_date", + "required": false, + "schema": { + "format": "date", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "version_downloads": { + "items": { + "$ref": "#/components/schemas/VersionDownload" + }, + "type": "array" + } + }, + "required": [ + "version_downloads" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get the download counts for a crate version.", + "tags": [ + "versions" + ] + } + }, + "/api/v1/crates/{name}/{version}/readme": { + "get": { + "operationId": "get_version_readme", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Version number", + "example": "1.0.0", + "in": "path", + "name": "version", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "url": { + "description": "The URL to the readme file.", + "example": "https://static.crates.io/readmes/serde/serde-1.0.0.html", + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object" + } + } + }, + "description": "Successful Response (for `content-type: application/json`)" + }, + "302": { + "description": "Successful Response (default)", + "headers": { + "location": { + "description": "The URL to the readme file.", + "schema": { + "type": "string" + } + } + } + } + }, + "summary": "Get the readme of a crate version.", + "tags": [ + "versions" + ] + } + }, + "/api/v1/crates/{name}/{version}/unyank": { + "put": { + "operationId": "unyank_version", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Version number", + "example": "1.0.0", + "in": "path", + "name": "version", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "example": true, + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "api_token": [] + }, + { + "cookie": [] + } + ], + "summary": "Unyank a crate version.", + "tags": [ + "versions" + ] + } + }, + "/api/v1/crates/{name}/{version}/yank": { + "delete": { + "description": "This does not delete a crate version, it makes the crate\nversion accessible only to crates that already have a\n`Cargo.lock` containing this version.\n\nNotes:\n\nVersion deletion is not implemented to avoid breaking builds,\nand the goal of yanking a crate is to prevent crates\nbeginning to depend on the yanked crate version.", + "operationId": "yank_version", + "parameters": [ + { + "description": "Name of the crate", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Version number", + "example": "1.0.0", + "in": "path", + "name": "version", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "example": true, + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "api_token": [] + }, + { + "cookie": [] + } + ], + "summary": "Yank a crate version.", + "tags": [ + "versions" + ] + } + }, + "/api/v1/github_oidc_configs": { + "put": { + "operationId": "create_github_oidc_config", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "github_oidc_config": { + "$ref": "#/components/schemas/GitHubOidcConfig" + } + }, + "required": [ + "github_oidc_config" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "cookie": [] + } + ], + "summary": "Create a new Trusted Publishing configuration for GitHub Actions.", + "tags": [ + "oidc" + ] + } + }, + "/api/v1/keywords": { + "get": { + "operationId": "list_keywords", + "parameters": [ + { + "description": "The sort order of the keywords.\n\nValid values: `alpha`, and `crates`.\n\nDefaults to `alpha`.", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "The page number to request.\n\nThis parameter is mutually exclusive with `seek` and not supported for\nall requests.", + "in": "query", + "name": "page", + "required": false, + "schema": { + "format": "int32", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "The number of items to request per page.", + "in": "query", + "name": "per_page", + "required": false, + "schema": { + "format": "int32", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "The seek key to request.\n\nThis parameter is mutually exclusive with `page` and not supported for\nall requests.\n\nThe seek key can usually be found in the `meta.next_page` field of\npaginated responses.", + "in": "query", + "name": "seek", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "keywords": { + "description": "The list of keywords.", + "items": { + "$ref": "#/components/schemas/Keyword" + }, + "type": "array" + }, + "meta": { + "properties": { + "total": { + "description": "The total number of keywords.", + "example": 123, + "format": "int64", + "type": "integer" + } + }, + "required": [ + "total" + ], + "type": "object" + } + }, + "required": [ + "keywords", + "meta" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List all keywords.", + "tags": [ + "keywords" + ] + } + }, + "/api/v1/keywords/{keyword}": { + "get": { + "operationId": "find_keyword", + "parameters": [ + { + "description": "The keyword to find", + "in": "path", + "name": "keyword", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "keyword": { + "$ref": "#/components/schemas/Keyword" + } + }, + "required": [ + "keyword" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get keyword metadata.", + "tags": [ + "keywords" + ] + } + }, + "/api/v1/me": { + "get": { + "operationId": "get_authenticated_user", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "owned_crates": { + "description": "The crates that the authenticated user owns.", + "items": { + "properties": { + "email_notifications": { + "deprecated": true, + "type": "boolean" + }, + "id": { + "description": "The opaque identifier of the crate.", + "example": 123, + "format": "int32", + "type": "integer" + }, + "name": { + "description": "The name of the crate.", + "example": "serde", + "type": "string" + } + }, + "required": [ + "id", + "name", + "email_notifications" + ], + "type": "object" + }, + "type": "array" + }, + "user": { + "$ref": "#/components/schemas/AuthenticatedUser", + "description": "The authenticated user." + } + }, + "required": [ + "user", + "owned_crates" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "cookie": [] + } + ], + "summary": "Get the currently authenticated user.", + "tags": [ + "users" + ] + } + }, + "/api/v1/me/crate_owner_invitations": { + "get": { + "operationId": "list_crate_owner_invitations_for_user", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "crate_owner_invitations": { + "description": "The list of crate owner invitations.", + "items": { + "$ref": "#/components/schemas/LegacyCrateOwnerInvitation" + }, + "type": "array" + }, + "users": { + "description": "The list of users referenced in the crate owner invitations.", + "items": { + "$ref": "#/components/schemas/User" + }, + "type": "array" + } + }, + "required": [ + "crate_owner_invitations", + "users" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "cookie": [] + } + ], + "summary": "List all crate owner invitations for the authenticated user.", + "tags": [ + "owners" + ] + } + }, + "/api/v1/me/crate_owner_invitations/accept/{token}": { + "put": { + "operationId": "accept_crate_owner_invitation_with_token", + "parameters": [ + { + "description": "Secret token sent to the user's email address", + "in": "path", + "name": "token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "crate_owner_invitation": { + "properties": { + "accepted": { + "description": "Whether the invitation was accepted.", + "example": true, + "type": "boolean" + }, + "crate_id": { + "description": "The opaque identifier for the crate this invitation is for.", + "example": 42, + "format": "int32", + "type": "integer" + } + }, + "required": [ + "crate_id", + "accepted" + ], + "type": "object" + } + }, + "required": [ + "crate_owner_invitation" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Accept a crate owner invitation with a token.", + "tags": [ + "owners" + ] + } + }, + "/api/v1/me/crate_owner_invitations/{crate_id}": { + "put": { + "operationId": "handle_crate_owner_invitation", + "parameters": [ + { + "description": "ID of the crate", + "in": "path", + "name": "crate_id", + "required": true, + "schema": { + "format": "int32", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "crate_owner_invitation": { + "properties": { + "accepted": { + "description": "Whether the invitation was accepted.", + "example": true, + "type": "boolean" + }, + "crate_id": { + "description": "The opaque identifier for the crate this invitation is for.", + "example": 42, + "format": "int32", + "type": "integer" + } + }, + "required": [ + "crate_id", + "accepted" + ], + "type": "object" + } + }, + "required": [ + "crate_owner_invitation" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "api_token": [] + }, + { + "cookie": [] + } + ], + "summary": "Accept or decline a crate owner invitation.", + "tags": [ + "owners" + ] + } + }, + "/api/v1/me/email_notifications": { + "put": { + "deprecated": true, + "description": "This endpoint was implemented for an experimental feature that was never\nfully implemented. It is now deprecated and will be removed in the future.", + "operationId": "update_email_notifications", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "example": true, + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "api_token": [] + }, + { + "cookie": [] + } + ], + "summary": "Update email notification settings for the authenticated user.", + "tags": [ + "users" + ] + } + }, + "/api/v1/me/tokens": { + "get": { + "operationId": "list_api_tokens", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "api_tokens": { + "items": { + "$ref": "#/components/schemas/ApiToken" + }, + "type": "array" + } + }, + "required": [ + "api_tokens" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "cookie": [] + } + ], + "summary": "List all API tokens of the authenticated user.", + "tags": [ + "api_tokens" + ] + }, + "put": { + "operationId": "create_api_token", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "api_token": { + "$ref": "#/components/schemas/EncodableApiTokenWithToken" + } + }, + "required": [ + "api_token" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "cookie": [] + } + ], + "summary": "Create a new API token.", + "tags": [ + "api_tokens" + ] + } + }, + "/api/v1/me/tokens/{id}": { + "delete": { + "operationId": "revoke_api_token", + "parameters": [ + { + "description": "ID of the API token", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int32", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "api_token": [] + }, + { + "cookie": [] + } + ], + "summary": "Revoke API token.", + "tags": [ + "api_tokens" + ] + }, + "get": { + "operationId": "find_api_token", + "parameters": [ + { + "description": "ID of the API token", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int32", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "api_token": { + "$ref": "#/components/schemas/ApiToken" + } + }, + "required": [ + "api_token" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "api_token": [] + }, + { + "cookie": [] + } + ], + "summary": "Find API token by id.", + "tags": [ + "api_tokens" + ] + } + }, + "/api/v1/me/updates": { + "get": { + "operationId": "get_authenticated_user_updates", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "meta": { + "properties": { + "more": { + "description": "Whether there are more versions to be loaded.", + "type": "boolean" + } + }, + "required": [ + "more" + ], + "type": "object" + }, + "versions": { + "description": "The list of recent versions of crates that the authenticated user follows.", + "items": { + "$ref": "#/components/schemas/Version" + }, + "type": "array" + } + }, + "required": [ + "versions", + "meta" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "cookie": [] + } + ], + "summary": "List versions of crates that the authenticated user follows.", + "tags": [ + "versions" + ] + } + }, + "/api/v1/oidc_tokens": { + "delete": { + "operationId": "revoke_oidc_token", + "responses": { + "204": { + "description": "Successful Response" + } + }, + "summary": "Revoke a temporary access token.", + "tags": [ + "oidc" + ] + }, + "put": { + "operationId": "exchange_oidc_token", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "token": { + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Exchange an OIDC token for a temporary access token.", + "tags": [ + "oidc" + ] + } + }, + "/api/v1/site_metadata": { + "get": { + "description": "Returns the current deployed commit SHA1 (or `unknown`), and whether the\nsystem is in read-only mode.", + "operationId": "get_site_metadata", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "commit": { + "description": "The SHA1 of the currently deployed commit.", + "example": "0aebe2cdfacae1229b93853b1c58f9352195f081", + "type": "string" + }, + "deployed_sha": { + "description": "The SHA1 of the currently deployed commit.", + "example": "0aebe2cdfacae1229b93853b1c58f9352195f081", + "type": "string" + }, + "read_only": { + "description": "Whether the crates.io service is in read-only mode.", + "type": "boolean" + } + }, + "required": [ + "deployed_sha", + "commit", + "read_only" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get crates.io metadata.", + "tags": [ + "other" + ] + } + }, + "/api/v1/summary": { + "get": { + "description": "This endpoint returns a summary of the most important data for the front\npage of crates.io.", + "operationId": "get_summary", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "just_updated": { + "description": "The 10 most recently updated crates.", + "items": { + "$ref": "#/components/schemas/Crate" + }, + "type": "array" + }, + "most_downloaded": { + "description": "The 10 crates with the highest total number of downloads.", + "items": { + "$ref": "#/components/schemas/Crate" + }, + "type": "array" + }, + "most_recently_downloaded": { + "description": "The 10 crates with the highest number of downloads within the last 90 days.", + "items": { + "$ref": "#/components/schemas/Crate" + }, + "type": "array" + }, + "new_crates": { + "description": "The 10 most recently created crates.", + "items": { + "$ref": "#/components/schemas/Crate" + }, + "type": "array" + }, + "num_crates": { + "description": "The total number of crates on crates.io.", + "example": 123456, + "format": "int64", + "type": "integer" + }, + "num_downloads": { + "description": "The total number of downloads across all crates.", + "example": 123456789, + "format": "int64", + "type": "integer" + }, + "popular_categories": { + "description": "The 10 most popular categories.", + "items": { + "$ref": "#/components/schemas/Category" + }, + "type": "array" + }, + "popular_keywords": { + "description": "The 10 most popular keywords.", + "items": { + "$ref": "#/components/schemas/Keyword" + }, + "type": "array" + } + }, + "required": [ + "num_downloads", + "num_crates", + "new_crates", + "most_downloaded", + "most_recently_downloaded", + "just_updated", + "popular_keywords", + "popular_categories" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get front page data.", + "tags": [ + "other" + ] + } + }, + "/api/v1/teams/{team}": { + "get": { + "operationId": "find_team", + "parameters": [ + { + "description": "Name of the team", + "example": "github:rust-lang:crates-io", + "in": "path", + "name": "team", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "team": { + "$ref": "#/components/schemas/Team" + } + }, + "required": [ + "team" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Find team by login.", + "tags": [ + "teams" + ] + } + }, + "/api/v1/tokens/current": { + "delete": { + "description": "This endpoint revokes the API token that is used to authenticate\nthe request.", + "operationId": "revoke_current_api_token", + "responses": { + "204": { + "description": "Successful Response" + } + }, + "security": [ + { + "api_token": [] + } + ], + "summary": "Revoke the current API token.", + "tags": [ + "api_tokens" + ] + } + }, + "/api/v1/users/{id}/resend": { + "put": { + "operationId": "resend_email_verification", + "parameters": [ + { + "description": "ID of the user", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int32", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "example": true, + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "api_token": [] + }, + { + "cookie": [] + } + ], + "summary": "Regenerate and send an email verification token.", + "tags": [ + "users" + ] + } + }, + "/api/v1/users/{id}/stats": { + "get": { + "description": "This currently only returns the total number of downloads for crates owned\nby the user.", + "operationId": "get_user_stats", + "parameters": [ + { + "description": "ID of the user", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int32", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "total_downloads": { + "description": "The total number of downloads for crates owned by the user.", + "example": 123456789, + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "total_downloads" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get user stats.", + "tags": [ + "users" + ] + } + }, + "/api/v1/users/{user}": { + "get": { + "operationId": "find_user", + "parameters": [ + { + "description": "Login name of the user", + "in": "path", + "name": "user", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "user": { + "$ref": "#/components/schemas/User" + } + }, + "required": [ + "user" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Find user by login.", + "tags": [ + "users" + ] + }, + "put": { + "description": "This endpoint allows users to update their email address and publish notifications settings.\n\nThe `id` parameter needs to match the ID of the currently authenticated user.", + "operationId": "update_user", + "parameters": [ + { + "description": "ID of the user", + "in": "path", + "name": "user", + "required": true, + "schema": { + "format": "int32", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "example": true, + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "api_token": [] + }, + { + "cookie": [] + } + ], + "summary": "Update user settings.", + "tags": [ + "users" + ] + } + } + }, + "servers": [ + { + "url": "https://crates.io" + }, + { + "url": "https://staging.crates.io" + } + ] +} diff --git a/src/tests/builders/version.rs b/src/tests/builders/version.rs index 5ad27f8fd2f..93cb1006b26 100644 --- a/src/tests/builders/version.rs +++ b/src/tests/builders/version.rs @@ -110,7 +110,9 @@ impl VersionBuilder { .maybe_created_at(self.created_at.as_ref()) .build(); - let vers = new_version.save(connection, "someone@example.com").await?; + let vers = new_version + .save(connection, Some("someone@example.com")) + .await?; let new_deps = self .dependencies diff --git a/src/tests/krate/publish/mod.rs b/src/tests/krate/publish/mod.rs index 5b7b218b6b3..fd72a1e27cd 100644 --- a/src/tests/krate/publish/mod.rs +++ b/src/tests/krate/publish/mod.rs @@ -14,6 +14,7 @@ mod keywords; mod links; mod manifest; mod max_size; +mod oidc; mod rate_limit; mod readme; mod similar_names; diff --git a/src/tests/krate/publish/oidc.rs b/src/tests/krate/publish/oidc.rs new file mode 100644 index 00000000000..b11d3356cd7 --- /dev/null +++ b/src/tests/krate/publish/oidc.rs @@ -0,0 +1,128 @@ +use crate::tests::builders::PublishBuilder; +use crate::tests::util::oidc::GitHubClaims; +use crate::tests::util::{MockTokenUser, RequestHelper, TestApp}; +use crates_io_github::{GitHubRepository, GitHubRepositoryOwner, MockGitHubClient}; +use crates_io_trusted_publishing::github::GITHUB_ISSUER_URL; +use crates_io_trusted_publishing::keystore::MockOidcKeyStore; +use crates_io_trusted_publishing::test_keys::encode_for_testing; +use http::StatusCode; +use insta::assert_json_snapshot; +use mockall::predicate::*; +use serde_json::json; + +/// Test the full flow of publishing a crate with OIDC authentication +/// (aka. "Trusted Publishing") +/// +/// This test will: +/// +/// 1. Publish a new crate via API token. +/// 2. Create a Trusted Publishing configuration. +/// 3. Generate a new OIDC token and exchange it for a temporary access token. +/// 4. Publish a new version of the crate using the temporary access token. +#[tokio::test(flavor = "multi_thread")] +async fn test_full_flow() -> anyhow::Result<()> { + const CRATE_NAME: &str = "foo"; + + const OWNER_NAME: &str = "rust-lang"; + const OWNER_ID: i32 = 42; + const REPOSITORY_NAME: &str = "foo-rs"; + const WORKFLOW_FILENAME: &str = "publish.yml"; + + let mut github_mock = MockGitHubClient::new(); + + github_mock + .expect_get_repository() + .with(eq(OWNER_NAME), eq(REPOSITORY_NAME), always()) + .returning(|_, _, _| { + Ok(GitHubRepository { + id: 13, + name: REPOSITORY_NAME.into(), + owner: GitHubRepositoryOwner { + id: OWNER_ID, + login: OWNER_NAME.into(), + }, + }) + }); + + let (app, client, cookie_client, api_token_client) = TestApp::full() + .with_github(github_mock) + .with_oidc_keystore(GITHUB_ISSUER_URL, MockOidcKeyStore::with_test_key()) + .with_token() + .await; + + // Step 1: Publish a new crate via API token + + let pb = PublishBuilder::new(CRATE_NAME, "1.0.0"); + let response = api_token_client.publish_crate(pb).await; + assert_eq!(response.status(), StatusCode::OK); + + // Step 2: Create a Trusted Publishing configuration + + let body = serde_json::to_vec(&json!({ + "github_oidc_config": { + "crate": CRATE_NAME, + "repository_owner": OWNER_NAME, + "repository_owner_id": null, + "repository_name": REPOSITORY_NAME, + "workflow_filename": WORKFLOW_FILENAME, + "environment": null, + } + }))?; + + let url = "/api/v1/github_oidc_configs"; + let response = cookie_client.put::<()>(url, body).await; + + assert_json_snapshot!(response.json(), { ".github_oidc_config.created_at" => "[datetime]" }, @r#" + { + "github_oidc_config": { + "crate": "foo", + "created_at": "[datetime]", + "environment": null, + "id": 1, + "repository_name": "foo-rs", + "repository_owner": "rust-lang", + "repository_owner_id": 42, + "workflow_filename": "publish.yml" + } + } + "#); + + assert_eq!(response.status(), StatusCode::OK); + + // Step 3: Generate a new OIDC token and exchange it for a temporary access token + + let claims = GitHubClaims::builder() + .owner_id(OWNER_ID) + .owner_name(OWNER_NAME) + .repository_name(REPOSITORY_NAME) + .workflow_filename(WORKFLOW_FILENAME) + .build(); + + let jwt = encode_for_testing(&claims)?; + + let body = serde_json::to_vec(&json!({ "jwt": jwt }))?; + let response = client.put::<()>("/api/v1/oidc_tokens", body).await; + let json = response.json(); + assert_json_snapshot!(json, { ".token" => "[token]" }, @r#" + { + "token": "[token]" + } + "#); + assert_eq!(response.status(), StatusCode::OK); + let token = json["token"].as_str().unwrap_or_default(); + + // Step 4: Publish a new version of the crate using the temporary access token + + let oidc_token_client = MockTokenUser::for_token(token, app); + + let pb = PublishBuilder::new(CRATE_NAME, "1.1.0"); + let response = oidc_token_client.publish_crate(pb).await; + assert_eq!(response.status(), StatusCode::OK); + + // Step 5: Revoke the temporary access token + + let response = oidc_token_client.delete::<()>("/api/v1/oidc_tokens").await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + Ok(()) +} diff --git a/src/tests/routes/mod.rs b/src/tests/routes/mod.rs index 413be48ef76..20f3ba6a558 100644 --- a/src/tests/routes/mod.rs +++ b/src/tests/routes/mod.rs @@ -17,6 +17,7 @@ pub mod crates; pub mod keywords; pub mod me; pub mod metrics; +mod oidc_tokens; mod private; pub mod session; pub mod summary; diff --git a/src/tests/routes/oidc_tokens/exchange.rs b/src/tests/routes/oidc_tokens/exchange.rs new file mode 100644 index 00000000000..b4d752ac1dd --- /dev/null +++ b/src/tests/routes/oidc_tokens/exchange.rs @@ -0,0 +1,415 @@ +use crate::tests::builders::CrateBuilder; +use crate::tests::util::oidc::GitHubClaims; +use crate::tests::util::{RequestHelper, TestApp}; +use crates_io_database::models::trusted_publishing::NewGitHubConfig; +use crates_io_trusted_publishing::github::GITHUB_ISSUER_URL; +use crates_io_trusted_publishing::keystore::MockOidcKeyStore; +use http::StatusCode; +use insta::assert_snapshot; +use jsonwebtoken::{EncodingKey, Header}; +use mockall::predicate::*; +use serde_json::json; + +const OWNER_NAME: &str = "rust-lang"; +const OWNER_ID: i32 = 42; +const REPOSITORY_NAME: &str = "foo-rs"; +const WORKFLOW_FILENAME: &str = "publish.yml"; + +fn new_oidc_config(crate_id: i32) -> NewGitHubConfig<'static> { + NewGitHubConfig { + crate_id, + repository_owner: OWNER_NAME, + repository_owner_id: OWNER_ID, + repository_name: REPOSITORY_NAME, + workflow_filename: WORKFLOW_FILENAME, + environment: None, + } +} + +fn default_claims() -> GitHubClaims { + GitHubClaims::builder() + .owner_id(OWNER_ID) + .owner_name(OWNER_NAME) + .repository_name(REPOSITORY_NAME) + .workflow_filename(WORKFLOW_FILENAME) + .build() +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_happy_path() -> anyhow::Result<()> { + let (app, client, cookie) = TestApp::full() + .with_oidc_keystore(GITHUB_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("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.put::<()>("/api/v1/oidc_tokens", body).await; + assert_eq!(response.status(), StatusCode::OK); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_happy_path_with_environment() -> anyhow::Result<()> { + let (app, client, cookie) = TestApp::full() + .with_oidc_keystore(GITHUB_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("foo", owner_id).build(&mut conn).await?; + + let mut new_oidc_config = new_oidc_config(krate.id); + new_oidc_config.environment = Some("prod"); + new_oidc_config.insert(&mut conn).await?; + + let mut claims = default_claims(); + claims.environment = Some("prod".into()); + + let body = claims.as_exchange_body()?; + let response = client.put::<()>("/api/v1/oidc_tokens", body).await; + assert_eq!(response.status(), StatusCode::OK); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_happy_path_with_ignored_environment() -> anyhow::Result<()> { + let (app, client, cookie) = TestApp::full() + .with_oidc_keystore(GITHUB_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("foo", owner_id).build(&mut conn).await?; + new_oidc_config(krate.id).insert(&mut conn).await?; + + let mut claims = default_claims(); + claims.environment = Some("prod".into()); + + let body = claims.as_exchange_body()?; + let response = client.put::<()>("/api/v1/oidc_tokens", body).await; + assert_eq!(response.status(), StatusCode::OK); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_broken_jwt() -> anyhow::Result<()> { + let (app, client, cookie) = TestApp::full() + .with_oidc_keystore(GITHUB_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("foo", owner_id).build(&mut conn).await?; + new_oidc_config(krate.id).insert(&mut conn).await?; + + let body = serde_json::to_vec(&json!({ "jwt": "broken" }))?; + let response = client.put::<()>("/api/v1/oidc_tokens", body).await; + assert_eq!(response.status(), StatusCode::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("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.put::<()>("/api/v1/oidc_tokens", body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"Unsupported JWT issuer"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_missing_key_id() -> anyhow::Result<()> { + let (app, client, cookie) = TestApp::full() + .with_oidc_keystore(GITHUB_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("foo", owner_id).build(&mut conn).await?; + new_oidc_config(krate.id).insert(&mut conn).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.put::<()>("/api/v1/oidc_tokens", body).await; + assert_eq!(response.status(), StatusCode::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(GITHUB_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.put::<()>("/api/v1/oidc_tokens", body).await; + assert_eq!(response.status(), StatusCode::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(GITHUB_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.put::<()>("/api/v1/oidc_tokens", body).await; + assert_eq!(response.status(), StatusCode::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 (app, client, cookie) = TestApp::full() + .with_oidc_keystore(GITHUB_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("foo", owner_id).build(&mut conn).await?; + new_oidc_config(krate.id).insert(&mut conn).await?; + + let mut claims = default_claims(); + claims.aud = "invalid-audience".into(); + + let body = claims.as_exchange_body()?; + let response = client.put::<()>("/api/v1/oidc_tokens", body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"Failed to decode JWT"}]}"#); + + Ok(()) +} + +/// Test that OIDC tokens can only be exchanged once +#[tokio::test(flavor = "multi_thread")] +async fn test_token_reuse() -> anyhow::Result<()> { + let (app, client, cookie) = TestApp::full() + .with_oidc_keystore(GITHUB_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("foo", owner_id).build(&mut conn).await?; + new_oidc_config(krate.id).insert(&mut conn).await?; + + let body = default_claims().as_exchange_body()?; + + // The first exchange should succeed + let response = client.put::<()>("/api/v1/oidc_tokens", body.clone()).await; + assert_eq!(response.status(), StatusCode::OK); + + // The second exchange should fail + let response = client.put::<()>("/api/v1/oidc_tokens", body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"JWT has already been used"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_repository() -> anyhow::Result<()> { + let (app, client, cookie) = TestApp::full() + .with_oidc_keystore(GITHUB_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("foo", owner_id).build(&mut conn).await?; + new_oidc_config(krate.id).insert(&mut conn).await?; + + let mut claims = default_claims(); + claims.repository = "what?".into(); + + let body = claims.as_exchange_body()?; + let response = client.put::<()>("/api/v1/oidc_tokens", body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"Unexpected `repository` value"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_workflow() -> anyhow::Result<()> { + let (app, client, cookie) = TestApp::full() + .with_oidc_keystore(GITHUB_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("foo", owner_id).build(&mut conn).await?; + new_oidc_config(krate.id).insert(&mut conn).await?; + + let mut claims = default_claims(); + claims.job_workflow_ref = "what?".into(); + + let body = claims.as_exchange_body()?; + let response = client.put::<()>("/api/v1/oidc_tokens", body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"Unexpected `job_workflow_ref` value"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_owner_id() -> anyhow::Result<()> { + let (app, client, cookie) = TestApp::full() + .with_oidc_keystore(GITHUB_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("foo", owner_id).build(&mut conn).await?; + new_oidc_config(krate.id).insert(&mut conn).await?; + + let mut claims = default_claims(); + claims.repository_owner_id = "what?".into(); + + let body = claims.as_exchange_body()?; + let response = client.put::<()>("/api/v1/oidc_tokens", body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"Unexpected `repository_owner_id` value"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_missing_config() -> anyhow::Result<()> { + let (_app, client, _cookie) = TestApp::full() + .with_oidc_keystore(GITHUB_ISSUER_URL, MockOidcKeyStore::with_test_key()) + .with_user() + .await; + + let body = default_claims().as_exchange_body()?; + let response = client.put::<()>("/api/v1/oidc_tokens", body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"No matching Trusted Publishing config found"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_missing_environment() -> anyhow::Result<()> { + let (app, client, cookie) = TestApp::full() + .with_oidc_keystore(GITHUB_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("foo", owner_id).build(&mut conn).await?; + + let mut new_oidc_config = new_oidc_config(krate.id); + new_oidc_config.environment = Some("prod"); + new_oidc_config.insert(&mut conn).await?; + + let body = default_claims().as_exchange_body()?; + let response = client.put::<()>("/api/v1/oidc_tokens", body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"No matching Trusted Publishing config found"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_wrong_environment() -> anyhow::Result<()> { + let (app, client, cookie) = TestApp::full() + .with_oidc_keystore(GITHUB_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("foo", owner_id).build(&mut conn).await?; + + let mut new_oidc_config = new_oidc_config(krate.id); + new_oidc_config.environment = Some("prod"); + new_oidc_config.insert(&mut conn).await?; + + let mut claims = default_claims(); + claims.environment = Some("not-prod".into()); + + let body = claims.as_exchange_body()?; + let response = client.put::<()>("/api/v1/oidc_tokens", body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"No matching Trusted Publishing config found"}]}"#); + + Ok(()) +} diff --git a/src/tests/routes/oidc_tokens/mod.rs b/src/tests/routes/oidc_tokens/mod.rs new file mode 100644 index 00000000000..06b1bc6a30c --- /dev/null +++ b/src/tests/routes/oidc_tokens/mod.rs @@ -0,0 +1 @@ +mod exchange; diff --git a/src/tests/util.rs b/src/tests/util.rs index 076eb242e88..55206573f41 100644 --- a/src/tests/util.rs +++ b/src/tests/util.rs @@ -48,6 +48,7 @@ pub mod github; pub mod insta; pub mod matchers; mod mock_request; +pub mod oidc; mod response; mod test_app; diff --git a/src/tests/util/oidc.rs b/src/tests/util/oidc.rs new file mode 100644 index 00000000000..f3e654d04ed --- /dev/null +++ b/src/tests/util/oidc.rs @@ -0,0 +1,95 @@ +use bon::bon; +use crates_io_trusted_publishing::EXPECTED_AUDIENCE; +use crates_io_trusted_publishing::github::GITHUB_ISSUER_URL; +use crates_io_trusted_publishing::test_keys::encode_for_testing; +use serde_json::json; + +#[derive(Debug, Serialize)] +pub struct GitHubClaims { + pub iss: String, + pub nbf: i64, + pub exp: i64, + pub iat: i64, + pub jti: String, + pub sub: String, + pub aud: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub environment: Option, + #[serde(rename = "ref")] + pub r#ref: String, + pub sha: String, + pub repository: String, + pub repository_owner: String, + pub actor_id: String, + pub repository_visibility: String, + pub repository_id: String, + pub repository_owner_id: String, + pub run_id: String, + pub run_number: String, + pub run_attempt: String, + pub runner_environment: String, + pub actor: String, + pub workflow: String, + pub head_ref: String, + pub base_ref: String, + pub event_name: String, + pub ref_type: String, + pub job_workflow_ref: String, +} + +#[bon] +impl GitHubClaims { + #[builder] + pub fn new( + owner_id: i32, + owner_name: &str, + repository_name: &str, + workflow_filename: &str, + environment: Option<&str>, + ) -> Self { + let now = chrono::Utc::now().timestamp(); + + Self { + iss: GITHUB_ISSUER_URL.into(), + nbf: now, + iat: now, + exp: now + 30 * 60, + jti: "example-id".into(), + sub: format!("repo:{owner_name}/{repository_name}"), + aud: EXPECTED_AUDIENCE.into(), + + environment: environment.map(|s| s.into()), + r#ref: "refs/heads/main".into(), + sha: "example-sha".into(), + repository: format!("{owner_name}/{repository_name}"), + repository_owner: owner_name.into(), + actor_id: "12".into(), + repository_visibility: "private".into(), + repository_id: "74".into(), + repository_owner_id: owner_id.to_string(), + run_id: "example-run-id".into(), + run_number: "10".into(), + run_attempt: "2".into(), + runner_environment: "github-hosted".into(), + actor: "octocat".into(), + workflow: "example-workflow".into(), + head_ref: "".into(), + base_ref: "".into(), + event_name: "workflow_dispatch".into(), + ref_type: "branch".into(), + job_workflow_ref: format!( + "{owner_name}/{repository_name}/.github/workflows/{workflow_filename}@refs/heads/main" + ), + } + } + + 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 }))?) + } +} diff --git a/src/tests/util/test_app.rs b/src/tests/util/test_app.rs index 4811bd82513..378cb422307 100644 --- a/src/tests/util/test_app.rs +++ b/src/tests/util/test_app.rs @@ -16,12 +16,13 @@ use crates_io_index::testing::UpstreamIndex; use crates_io_index::{Credentials, RepositoryConfig}; use crates_io_team_repo::MockTeamRepo; use crates_io_test_db::TestDatabase; +use crates_io_trusted_publishing::keystore::{MockOidcKeyStore, OidcKeyStore}; use crates_io_worker::Runner; use diesel_async::AsyncPgConnection; use futures_util::TryStreamExt; use oauth2::{ClientId, ClientSecret}; use regex::Regex; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::LazyLock; use std::{rc::Rc, sync::Arc, time::Duration}; use tokio::runtime::Handle; @@ -101,6 +102,7 @@ impl TestApp { use_chaos_proxy: false, team_repo: MockTeamRepo::new(), github: None, + oidc_key_stores: Default::default(), } } @@ -242,6 +244,7 @@ pub struct TestAppBuilder { use_chaos_proxy: bool, team_repo: MockTeamRepo, github: Option, + oidc_key_stores: HashMap>, } impl TestAppBuilder { @@ -280,7 +283,7 @@ impl TestAppBuilder { (primary_proxy, replica_proxy) }; - let (app, router) = build_app(self.config, self.github); + let (app, router) = build_app(self.config, self.github, self.oidc_key_stores); let runner = if self.build_job_runner { let index = self @@ -388,6 +391,16 @@ impl TestAppBuilder { self } + pub fn with_oidc_keystore( + mut self, + issuer_url: impl Into, + keystore: MockOidcKeyStore, + ) -> Self { + self.oidc_key_stores + .insert(issuer_url.into(), Box::new(keystore)); + self + } + pub fn with_team_repo(mut self, team_repo: MockTeamRepo) -> Self { self.team_repo = team_repo; self @@ -480,7 +493,11 @@ fn simple_config() -> config::Server { } } -fn build_app(config: config::Server, github: Option) -> (Arc, axum::Router) { +fn build_app( + config: config::Server, + github: Option, + oidc_key_stores: HashMap>, +) -> (Arc, axum::Router) { // Use the in-memory email backend for all tests, allowing tests to analyze the emails sent by // the application. This will also prevent cluttering the filesystem. let emails = Emails::new_in_memory(); @@ -492,6 +509,7 @@ fn build_app(config: config::Server, github: Option) -> (Arc