diff --git a/crates/crates_io_database/src/models/version.rs b/crates/crates_io_database/src/models/version.rs index 29f62acc05f..8ca1a2edfa2 100644 --- a/crates/crates_io_database/src/models/version.rs +++ b/crates/crates_io_database/src/models/version.rs @@ -90,7 +90,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>, diff --git a/src/controllers/krate/publish.rs b/src/controllers/krate/publish.rs index 83eca6e663e..c635b6be596 100644 --- a/src/controllers/krate/publish.rs +++ b/src/controllers/krate/publish.rs @@ -1,7 +1,7 @@ //! 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::worker::jobs::{ self, CheckTyposquat, SendPublishNotificationsJob, UpdateDefaultVersion, }; @@ -11,7 +11,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,8 +19,8 @@ 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}; @@ -38,12 +38,13 @@ use crate::middleware::log_request::RequestLogExt; use crate::models::token::EndpointScope; use crate::rate_limiter::LimitedAction; use crate::schema::*; -use crate::util::errors::{AppResult, BoxedAppError, bad_request, custom, internal}; +use crate::util::errors::{AppResult, BoxedAppError, bad_request, custom, forbidden, internal}; use crate::views::{ EncodableCrate, EncodableCrateDependency, GoodCrate, PublishMetadata, PublishWarnings, }; -use crates_io_database::models::versions_published_by; +use crates_io_database::models::{User, versions_published_by}; use crates_io_diesel_helpers::canon_crate_name; +use crates_io_trustpub::access_token::AccessToken; const MISSING_RIGHTS_ERROR_MESSAGE: &str = "this crate exists but you don't seem to be an owner. \ If you believe this is a mistake, perhaps you need \ @@ -52,6 +53,24 @@ const MISSING_RIGHTS_ERROR_MESSAGE: &str = "this crate exists but you don't seem const MAX_DESCRIPTION_LENGTH: usize = 1000; +enum AuthType { + Regular(Box), + TrustPub, +} + +impl AuthType { + fn user(&self) -> Option<&User> { + match self { + AuthType::Regular(auth) => Some(auth.user()), + AuthType::TrustPub => None, + } + } + + fn user_id(&self) -> Option { + self.user().map(|u| u.id) + } +} + /// Publish a new crate/version. /// /// Used by `cargo publish` to publish a new crate or to publish a new version of an @@ -61,6 +80,7 @@ const MAX_DESCRIPTION_LENGTH: usize = 1000; path = "/api/v1/crates/new", security( ("api_token" = []), + ("trustpub_token" = []), ("cookie" = []), ), tag = "publish", @@ -126,35 +146,79 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult EndpointScope::PublishUpdate, - None => EndpointScope::PublishNew, - }; + // Trusted publishing tokens are distinguished from regular crates.io API + // tokens because they use the `Bearer` auth scheme, so we look for that + // specific prefix. + let trustpub_token = req + .headers + .get(header::AUTHORIZATION) + .and_then(|h| { + let mut split = h.as_bytes().splitn(2, |b| *b == b' '); + Some((split.next()?, split.next()?)) + }) + .filter(|(scheme, _token)| scheme.eq_ignore_ascii_case(b"Bearer")) + .map(|(_scheme, token)| token.trim_ascii()) + .map(AccessToken::from_byte_str) + .transpose() + .map_err(|_| forbidden("Invalid authentication token"))?; + + let auth = if let Some(trustpub_token) = trustpub_token { + let Some(existing_crate) = &existing_crate else { + let error = forbidden("Trusted Publishing tokens do not support creating new crates"); + return Err(error); + }; - let auth = AuthCheck::default() - .with_endpoint_scope(endpoint_scope) - .for_crate(&metadata.name) - .check(&req, &mut conn) - .await?; + let hashed_token = trustpub_token.sha256(); - 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 crate_ids: Vec> = trustpub_tokens::table + .filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice())) + .filter(trustpub_tokens::expires_at.gt(now)) + .select(trustpub_tokens::crate_ids) + .get_result(&mut conn) + .await + .optional()? + .ok_or_else(|| forbidden("Invalid authentication token"))?; + + if !crate_ids.contains(&Some(existing_crate.id)) { + let name = &existing_crate.name; + let error = format!("The provided access token is not valid for crate `{name}`"); + return Err(forbidden(error)); + } + + AuthType::TrustPub + } else { + let endpoint_scope = match existing_crate { + Some(_) => EndpointScope::PublishUpdate, + None => EndpointScope::PublishNew, + }; + + let auth = AuthCheck::default() + .with_endpoint_scope(endpoint_scope) + .for_crate(&metadata.name) + .check(&req, &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::Regular(Box::new(auth)) }; - app.rate_limiter - .check_rate_limit(auth.user().id, rate_limit_action, &mut conn) - .await?; + let verified_email_address = if let Some(user) = auth.user() { + let verified_email_address = user.verified_email(&mut conn).await?; + Some(verified_email_address.ok_or_else(|| verified_email_error(&app.config.domain_name))?) + } else { + None + }; + + if let Some(user_id) = auth.user_id() { + // 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(user_id, rate_limit_action, &mut conn) + .await?; + } let max_upload_size = existing_crate .as_ref() @@ -343,9 +407,6 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult AppResult krate, - None => persist.update(conn).await?, - }; + let krate = if let Some(user) = auth.user() { + // 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)); - } + 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 + } else { + // Trusted Publishing does not support creating new crates + persist.update(conn).await? + }; if krate.name != *name { return Err(bad_request(format_args!( @@ -418,7 +486,7 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult AppResult AppResult AppResult<()> { } } +fn verified_email_error(domain: &str) -> BoxedAppError { + bad_request(format!( + "A verified email address is required to publish crates to crates.io. \ + Visit https://{domain}/settings/profile to set and verify your email address.", + )) +} + fn convert_dependencies( normal_deps: Option<&DepsSet>, dev_deps: Option<&DepsSet>, diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap index 872ca3253c7..83dd55ed06c 100644 --- a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap +++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap @@ -2020,6 +2020,9 @@ expression: response.json() { "api_token": [] }, + { + "trustpub_token": [] + }, { "cookie": [] } diff --git a/src/tests/krate/publish/mod.rs b/src/tests/krate/publish/mod.rs index 5b7b218b6b3..9e2715b14e8 100644 --- a/src/tests/krate/publish/mod.rs +++ b/src/tests/krate/publish/mod.rs @@ -19,4 +19,5 @@ mod readme; mod similar_names; mod tarball; mod timestamps; +mod trustpub; mod validation; diff --git a/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__trustpub__full_flow-3.snap b/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__trustpub__full_flow-3.snap new file mode 100644 index 00000000000..5dda9873198 --- /dev/null +++ b/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__trustpub__full_flow-3.snap @@ -0,0 +1,42 @@ +--- +source: src/tests/krate/publish/trustpub.rs +expression: response.json() +--- +{ + "crate": { + "badges": [], + "categories": null, + "created_at": "[datetime]", + "default_version": "1.1.0", + "description": "description", + "documentation": null, + "downloads": 0, + "exact_match": false, + "homepage": null, + "id": "foo", + "keywords": null, + "links": { + "owner_team": "/api/v1/crates/foo/owner_team", + "owner_user": "/api/v1/crates/foo/owner_user", + "owners": "/api/v1/crates/foo/owners", + "reverse_dependencies": "/api/v1/crates/foo/reverse_dependencies", + "version_downloads": "/api/v1/crates/foo/downloads", + "versions": "/api/v1/crates/foo/versions" + }, + "max_stable_version": "1.1.0", + "max_version": "1.1.0", + "name": "foo", + "newest_version": "1.1.0", + "num_versions": 2, + "recent_downloads": null, + "repository": null, + "updated_at": "[datetime]", + "versions": null, + "yanked": false + }, + "warnings": { + "invalid_badges": [], + "invalid_categories": [], + "other": [] + } +} diff --git a/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__trustpub__full_flow-4.snap b/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__trustpub__full_flow-4.snap new file mode 100644 index 00000000000..c9a9656f3c1 --- /dev/null +++ b/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__trustpub__full_flow-4.snap @@ -0,0 +1,38 @@ +--- +source: src/tests/krate/publish/trustpub.rs +expression: response.json() +--- +{ + "version": { + "audit_actions": [], + "bin_names": [], + "checksum": "f057a5f8094591ca4faccdbcb3cddaf7299f0045c3076065956308eee13f99ac", + "crate": "foo", + "crate_size": 148, + "created_at": "[datetime]", + "description": "description", + "dl_path": "/api/v1/crates/foo/1.1.0/download", + "documentation": null, + "downloads": 0, + "edition": null, + "features": {}, + "has_lib": false, + "homepage": null, + "id": 2, + "lib_links": null, + "license": "MIT", + "links": { + "authors": "/api/v1/crates/foo/1.1.0/authors", + "dependencies": "/api/v1/crates/foo/1.1.0/dependencies", + "version_downloads": "/api/v1/crates/foo/1.1.0/downloads" + }, + "num": "1.1.0", + "published_by": null, + "readme_path": "/api/v1/crates/foo/1.1.0/readme", + "repository": null, + "rust_version": null, + "updated_at": "[datetime]", + "yank_message": null, + "yanked": false + } +} diff --git a/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__trustpub__full_flow-5.snap b/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__trustpub__full_flow-5.snap new file mode 100644 index 00000000000..337951e30e2 --- /dev/null +++ b/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__trustpub__full_flow-5.snap @@ -0,0 +1,50 @@ +--- +source: src/tests/krate/publish/trustpub.rs +expression: app.emails_snapshot().await +--- +To: foo@example.com +From: crates.io +Subject: crates.io: Successfully published foo@1.0.0 +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +Hello foo! + +A new version of the package foo (1.0.0) was published by your account (https://crates.io/users/foo) at [0000-00-00T00:00:00Z]. + +If you have questions or security concerns, you can contact us at help@crates.io. If you would like to stop receiving these security notifications, you can disable them in your account settings. +---------------------------------------- + +To: foo@example.com +From: crates.io +Subject: crates.io: Trusted Publishing configration added to foo +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +Hello foo! + +crates.io user foo has added a new "Trusted Publishing" configuration for GitHub Actions to a crate that you manage (foo). Trusted publishers act as trusted users and can publish new versions of the crate automatically. + +Trusted Publishing configuration: + +- Repository owner: rust-lang +- Repository name: foo-rs +- Workflow filename: publish.yml +- Environment: (not set) + +If you did not make this change and you think it was made maliciously, you can remove the configuration from the crate via the "Settings" tab on the crate's page. + +If you are unable to revert the change and need to do so, you can email help@crates.io to communicate with the crates.io support team. +---------------------------------------- + +To: foo@example.com +From: crates.io +Subject: crates.io: Successfully published foo@1.1.0 +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +Hello foo! + +A new version of the package foo (1.1.0) was published at [0000-00-00T00:00:00Z]. + +If you have questions or security concerns, you can contact us at help@crates.io. If you would like to stop receiving these security notifications, you can disable them in your account settings. diff --git a/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__trustpub__happy_path.snap b/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__trustpub__happy_path.snap new file mode 100644 index 00000000000..5dda9873198 --- /dev/null +++ b/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__trustpub__happy_path.snap @@ -0,0 +1,42 @@ +--- +source: src/tests/krate/publish/trustpub.rs +expression: response.json() +--- +{ + "crate": { + "badges": [], + "categories": null, + "created_at": "[datetime]", + "default_version": "1.1.0", + "description": "description", + "documentation": null, + "downloads": 0, + "exact_match": false, + "homepage": null, + "id": "foo", + "keywords": null, + "links": { + "owner_team": "/api/v1/crates/foo/owner_team", + "owner_user": "/api/v1/crates/foo/owner_user", + "owners": "/api/v1/crates/foo/owners", + "reverse_dependencies": "/api/v1/crates/foo/reverse_dependencies", + "version_downloads": "/api/v1/crates/foo/downloads", + "versions": "/api/v1/crates/foo/versions" + }, + "max_stable_version": "1.1.0", + "max_version": "1.1.0", + "name": "foo", + "newest_version": "1.1.0", + "num_versions": 2, + "recent_downloads": null, + "repository": null, + "updated_at": "[datetime]", + "versions": null, + "yanked": false + }, + "warnings": { + "invalid_badges": [], + "invalid_categories": [], + "other": [] + } +} diff --git a/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__trustpub__happy_path_with_fancy_auth_header.snap b/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__trustpub__happy_path_with_fancy_auth_header.snap new file mode 100644 index 00000000000..5dda9873198 --- /dev/null +++ b/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__trustpub__happy_path_with_fancy_auth_header.snap @@ -0,0 +1,42 @@ +--- +source: src/tests/krate/publish/trustpub.rs +expression: response.json() +--- +{ + "crate": { + "badges": [], + "categories": null, + "created_at": "[datetime]", + "default_version": "1.1.0", + "description": "description", + "documentation": null, + "downloads": 0, + "exact_match": false, + "homepage": null, + "id": "foo", + "keywords": null, + "links": { + "owner_team": "/api/v1/crates/foo/owner_team", + "owner_user": "/api/v1/crates/foo/owner_user", + "owners": "/api/v1/crates/foo/owners", + "reverse_dependencies": "/api/v1/crates/foo/reverse_dependencies", + "version_downloads": "/api/v1/crates/foo/downloads", + "versions": "/api/v1/crates/foo/versions" + }, + "max_stable_version": "1.1.0", + "max_version": "1.1.0", + "name": "foo", + "newest_version": "1.1.0", + "num_versions": 2, + "recent_downloads": null, + "repository": null, + "updated_at": "[datetime]", + "versions": null, + "yanked": false + }, + "warnings": { + "invalid_badges": [], + "invalid_categories": [], + "other": [] + } +} diff --git a/src/tests/krate/publish/trustpub.rs b/src/tests/krate/publish/trustpub.rs new file mode 100644 index 00000000000..41fe9765416 --- /dev/null +++ b/src/tests/krate/publish/trustpub.rs @@ -0,0 +1,331 @@ +use crate::tests::builders::{CrateBuilder, PublishBuilder}; +use crate::tests::util::{MockTokenUser, RequestHelper, TestApp}; +use chrono::{TimeDelta, Utc}; +use crates_io_database::models::trustpub::NewToken; +use crates_io_github::{GitHubUser, MockGitHubClient}; +use crates_io_trustpub::access_token::AccessToken; +use crates_io_trustpub::github::GITHUB_ISSUER_URL; +use crates_io_trustpub::github::test_helpers::FullGitHubClaims; +use crates_io_trustpub::keystore::MockOidcKeyStore; +use crates_io_trustpub::test_keys::encode_for_testing; +use diesel::QueryResult; +use diesel_async::AsyncPgConnection; +use http::StatusCode; +use insta::{assert_json_snapshot, assert_snapshot}; +use mockall::predicate::*; +use p256::ecdsa::signature::digest::Output; +use secrecy::ExposeSecret; +use serde_json::json; +use sha2::Sha256; + +/// 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. +/// 5. Revoke 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_user() + .with(eq(OWNER_NAME), always()) + .returning(|_, _| { + Ok(GitHubUser { + avatar_url: None, + email: None, + id: OWNER_ID, + login: OWNER_NAME.into(), + name: None, + }) + }); + + 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_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/trusted_publishing/github_configs"; + let response = cookie_client.put::<()>(url, body).await; + + assert_json_snapshot!(response.json(), { ".github_config.created_at" => "[datetime]" }, @r#" + { + "github_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 = FullGitHubClaims::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/trusted_publishing/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 header = format!("Bearer {}", token); + let oidc_token_client = MockTokenUser::with_auth_header(header, app.clone()); + + let pb = PublishBuilder::new(CRATE_NAME, "1.1.0"); + let response = oidc_token_client.publish_crate(pb).await; + assert_eq!(response.status(), StatusCode::OK); + assert_json_snapshot!(response.json(), { + ".crate.created_at" => "[datetime]", + ".crate.updated_at" => "[datetime]", + }); + + // Step 4b: Verify the new version was published successfully + + let url = format!("/api/v1/crates/{CRATE_NAME}/1.1.0"); + let response = client.get::<()>(&url).await; + assert_eq!(response.status(), StatusCode::OK); + assert_json_snapshot!(response.json(), { + ".version.created_at" => "[datetime]", + ".version.updated_at" => "[datetime]", + ".version.audit_actions[].time" => "[datetime]", + }); + + // Step 5: Revoke the temporary access token + + let response = oidc_token_client + .delete::<()>("/api/v1/trusted_publishing/tokens") + .await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + assert_snapshot!(app.emails_snapshot().await); + + Ok(()) +} + +fn generate_token() -> (String, Output) { + let token = AccessToken::generate(); + (token.finalize().expose_secret().to_string(), token.sha256()) +} + +async fn new_token(conn: &mut AsyncPgConnection, crate_id: i32) -> QueryResult { + let (token, hashed_token) = generate_token(); + + let new_token = NewToken { + expires_at: Utc::now() + TimeDelta::minutes(30), + hashed_token: hashed_token.as_slice(), + crate_ids: &[crate_id], + }; + + new_token.insert(conn).await?; + + Ok(token) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_happy_path() -> anyhow::Result<()> { + let (app, _client, cookie_client) = TestApp::full().with_user().await; + + let mut conn = app.db_conn().await; + + let owner_id = cookie_client.as_model().id; + let krate = CrateBuilder::new("foo", owner_id).build(&mut conn).await?; + + let token = new_token(&mut conn, krate.id).await?; + + let header = format!("Bearer {}", token); + let oidc_token_client = MockTokenUser::with_auth_header(header, app); + + let pb = PublishBuilder::new(&krate.name, "1.1.0"); + let response = oidc_token_client.publish_crate(pb).await; + assert_eq!(response.status(), StatusCode::OK); + assert_json_snapshot!(response.json(), { + ".crate.created_at" => "[datetime]", + ".crate.updated_at" => "[datetime]", + }); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_happy_path_with_fancy_auth_header() -> anyhow::Result<()> { + let (app, _client, cookie_client) = TestApp::full().with_user().await; + + let mut conn = app.db_conn().await; + + let owner_id = cookie_client.as_model().id; + let krate = CrateBuilder::new("foo", owner_id).build(&mut conn).await?; + + let token = new_token(&mut conn, krate.id).await?; + + let header = format!("beaReR {}", token); + let oidc_token_client = MockTokenUser::with_auth_header(header, app); + + let pb = PublishBuilder::new(&krate.name, "1.1.0"); + let response = oidc_token_client.publish_crate(pb).await; + assert_eq!(response.status(), StatusCode::OK); + assert_json_snapshot!(response.json(), { + ".crate.created_at" => "[datetime]", + ".crate.updated_at" => "[datetime]", + }); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_authorization_header_format() -> anyhow::Result<()> { + let (app, _client, cookie_client) = TestApp::full().with_user().await; + + let mut conn = app.db_conn().await; + + let owner_id = cookie_client.as_model().id; + let krate = CrateBuilder::new("foo", owner_id).build(&mut conn).await?; + + // Create a client with an invalid authorization header (missing "Bearer " prefix) + let header = "invalid-format".to_string(); + let oidc_token_client = MockTokenUser::with_auth_header(header, app); + + let pb = PublishBuilder::new(&krate.name, "1.1.0"); + let response = oidc_token_client.publish_crate(pb).await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"The given API token does not match the format used by crates.io. Tokens generated before 2020-07-14 were generated with an insecure random number generator, and have been revoked. You can generate a new token at https://crates.io/me. For more information please see https://blog.rust-lang.org/2020/07/14/crates-io-security-advisory.html. We apologize for any inconvenience."}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_token_format() -> anyhow::Result<()> { + let (app, _client, cookie_client) = TestApp::full().with_user().await; + + let mut conn = app.db_conn().await; + + let owner_id = cookie_client.as_model().id; + let krate = CrateBuilder::new("foo", owner_id).build(&mut conn).await?; + + // Create a client with an invalid authorization header (missing "Bearer " prefix) + let header = "Bearer invalid-token".to_string(); + let oidc_token_client = MockTokenUser::with_auth_header(header, app); + + let pb = PublishBuilder::new(&krate.name, "1.1.0"); + let response = oidc_token_client.publish_crate(pb).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Invalid authentication token"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_non_existent_token() -> anyhow::Result<()> { + let (app, _client, cookie_client) = TestApp::full().with_user().await; + + let mut conn = app.db_conn().await; + + let owner_id = cookie_client.as_model().id; + let krate = CrateBuilder::new("foo", owner_id).build(&mut conn).await?; + + // Generate a valid token format, but it doesn't exist in the database + let (token, _) = generate_token(); + let header = format!("Bearer {}", token); + let oidc_token_client = MockTokenUser::with_auth_header(header, app); + + let pb = PublishBuilder::new(&krate.name, "1.1.0"); + let response = oidc_token_client.publish_crate(pb).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Invalid authentication token"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_non_existent_token_with_new_crate() -> anyhow::Result<()> { + let (app, _client, _cookie_client) = TestApp::full().with_user().await; + + // Generate a valid token format, but it doesn't exist in the database + let (token, _) = generate_token(); + let header = format!("Bearer {}", token); + let oidc_token_client = MockTokenUser::with_auth_header(header, app); + + let pb = PublishBuilder::new("foo", "1.0.0"); + let response = oidc_token_client.publish_crate(pb).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Trusted Publishing tokens do not support creating new crates"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_token_for_wrong_crate() -> anyhow::Result<()> { + let (app, _client, cookie_client) = TestApp::full().with_user().await; + + let mut conn = app.db_conn().await; + + let owner_id = cookie_client.as_model().id; + let krate = CrateBuilder::new("foo", owner_id).build(&mut conn).await?; + let token = new_token(&mut conn, krate.id).await?; + + let header = format!("Bearer {}", token); + let oidc_token_client = MockTokenUser::with_auth_header(header, app); + + let krate = CrateBuilder::new("bar", owner_id).build(&mut conn).await?; + + let pb = PublishBuilder::new(&krate.name, "1.1.0"); + let response = oidc_token_client.publish_crate(pb).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"The provided access token is not valid for crate `bar`"}]}"#); + + Ok(()) +}