Skip to content

Commit 3e1c80c

Browse files
committed
WIP
1 parent 8195e20 commit 3e1c80c

File tree

3 files changed

+236
-46
lines changed

3 files changed

+236
-46
lines changed

src/controllers/krate/publish.rs

Lines changed: 104 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Functionality related to publishing a new crate or version of a crate.
22
33
use crate::app::AppState;
4-
use crate::auth::AuthCheck;
4+
use crate::auth::{AuthCheck, Authentication};
55
use crate::worker::jobs::{
66
self, CheckTyposquat, SendPublishNotificationsJob, UpdateDefaultVersion,
77
};
@@ -11,16 +11,16 @@ use cargo_manifest::{Dependency, DepsSet, TargetDepsSet};
1111
use chrono::{DateTime, SecondsFormat, Utc};
1212
use crates_io_tarball::{TarballError, process_tarball};
1313
use crates_io_worker::{BackgroundJob, EnqueueError};
14-
use diesel::dsl::{exists, select};
14+
use diesel::dsl::{exists, now, select};
1515
use diesel::prelude::*;
1616
use diesel::sql_types::Timestamptz;
1717
use diesel_async::scoped_futures::ScopedFutureExt;
1818
use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl};
1919
use futures_util::TryFutureExt;
2020
use futures_util::TryStreamExt;
2121
use hex::ToHex;
22-
use http::StatusCode;
2322
use http::request::Parts;
23+
use http::{StatusCode, header};
2424
use sha2::{Digest, Sha256};
2525
use std::collections::HashMap;
2626
use tokio::io::{AsyncRead, AsyncReadExt};
@@ -43,6 +43,7 @@ use crate::views::{
4343
EncodableCrate, EncodableCrateDependency, GoodCrate, PublishMetadata, PublishWarnings,
4444
};
4545
use crates_io_diesel_helpers::canon_crate_name;
46+
use crates_io_trustpub::access_token::AccessToken;
4647

4748
const MISSING_RIGHTS_ERROR_MESSAGE: &str = "this crate exists but you don't seem to be an owner. \
4849
If you believe this is a mistake, perhaps you need \
@@ -51,6 +52,11 @@ const MISSING_RIGHTS_ERROR_MESSAGE: &str = "this crate exists but you don't seem
5152

5253
const MAX_DESCRIPTION_LENGTH: usize = 1000;
5354

55+
enum AuthType {
56+
Regular(Box<Authentication>),
57+
Oidc(),
58+
}
59+
5460
/// Publish a new crate/version.
5561
///
5662
/// Used by `cargo publish` to publish a new crate or to publish a new version of an
@@ -130,30 +136,66 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
130136
None => EndpointScope::PublishNew,
131137
};
132138

133-
let auth = AuthCheck::default()
134-
.with_endpoint_scope(endpoint_scope)
135-
.for_crate(&metadata.name)
136-
.check(&req, &mut conn)
137-
.await?;
139+
let access_token = req
140+
.headers
141+
.get(header::AUTHORIZATION)
142+
.and_then(|h| h.as_bytes().strip_prefix(b"Bearer "))
143+
.filter(|b| b.starts_with(AccessToken::PREFIX.as_bytes()))
144+
.map(AccessToken::from_byte_str)
145+
.transpose()
146+
.map_err(|_| bad_request("Invalid authentication token"))?;
147+
148+
let auth = match (access_token, &existing_crate) {
149+
(Some(access_token), Some(existing_crate)) => {
150+
let hashed_token = access_token.sha256();
151+
152+
trustpub_tokens::table
153+
.filter(trustpub_tokens::crate_ids.contains(vec![existing_crate.id]))
154+
.filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice()))
155+
.filter(trustpub_tokens::expires_at.gt(now))
156+
.select(trustpub_tokens::id)
157+
.get_result::<i64>(&mut conn)
158+
.await?;
159+
160+
AuthType::Oidc()
161+
}
162+
_ => {
163+
let auth = AuthCheck::default()
164+
.with_endpoint_scope(endpoint_scope)
165+
.for_crate(&metadata.name)
166+
.check(&req, &mut conn)
167+
.await?;
168+
169+
AuthType::Regular(Box::new(auth))
170+
}
171+
};
138172

139-
let verified_email_address = auth.user().verified_email(&mut conn).await?;
140-
let verified_email_address = verified_email_address.ok_or_else(|| {
141-
bad_request(format!(
142-
"A verified email address is required to publish crates to crates.io. \
173+
let verified_email_address = match &auth {
174+
AuthType::Regular(auth) => {
175+
let verified_email_address = auth.user().verified_email(&mut conn).await?;
176+
let verified_email_address = verified_email_address.ok_or_else(|| {
177+
bad_request(format!(
178+
"A verified email address is required to publish crates to crates.io. \
143179
Visit https://{}/settings/profile to set and verify your email address.",
144-
app.config.domain_name,
145-
))
146-
})?;
147-
148-
// Use a different rate limit whether this is a new or an existing crate.
149-
let rate_limit_action = match existing_crate {
150-
Some(_) => LimitedAction::PublishUpdate,
151-
None => LimitedAction::PublishNew,
180+
app.config.domain_name,
181+
))
182+
})?;
183+
Some(verified_email_address)
184+
}
185+
AuthType::Oidc() => None,
152186
};
153187

154-
app.rate_limiter
155-
.check_rate_limit(auth.user().id, rate_limit_action, &mut conn)
156-
.await?;
188+
if let AuthType::Regular(auth) = &auth {
189+
// Use a different rate limit whether this is a new or an existing crate.
190+
let rate_limit_action = match existing_crate {
191+
Some(_) => LimitedAction::PublishUpdate,
192+
None => LimitedAction::PublishNew,
193+
};
194+
195+
app.rate_limiter
196+
.check_rate_limit(auth.user().id, rate_limit_action, &mut conn)
197+
.await?;
198+
}
157199

158200
let max_upload_size = existing_crate
159201
.as_ref()
@@ -342,9 +384,6 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
342384
validate_dependency(dep)?;
343385
}
344386

345-
let api_token_id = auth.api_token_id();
346-
let user = auth.user();
347-
348387
// Create a transaction on the database, if there are no errors,
349388
// commit the transactions to record a new or updated crate.
350389
conn.transaction(|conn| async move {
@@ -368,17 +407,29 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
368407
return Err(bad_request("cannot upload a crate with a reserved name"));
369408
}
370409

371-
// To avoid race conditions, we try to insert
372-
// first so we know whether to add an owner
373-
let krate = match persist.create(conn, user.id).await.optional()? {
374-
Some(krate) => krate,
375-
None => persist.update(conn).await?,
376-
};
410+
let krate = match &auth {
411+
AuthType::Regular(auth) => {
412+
let user = auth.user();
377413

378-
let owners = krate.owners(conn).await?;
379-
if Rights::get(user, &*app.github, &owners).await? < Rights::Publish {
380-
return Err(custom(StatusCode::FORBIDDEN, MISSING_RIGHTS_ERROR_MESSAGE));
381-
}
414+
// To avoid race conditions, we try to insert
415+
// first so we know whether to add an owner
416+
let krate = match persist.create(conn, user.id).await.optional()? {
417+
Some(krate) => krate,
418+
None => persist.update(conn).await?,
419+
};
420+
421+
let owners = krate.owners(conn).await?;
422+
if Rights::get(user, &*app.github, &owners).await? < Rights::Publish {
423+
return Err(custom(StatusCode::FORBIDDEN, MISSING_RIGHTS_ERROR_MESSAGE));
424+
}
425+
426+
krate
427+
}
428+
AuthType::Oidc() => {
429+
// OIDC does not support creating new crates
430+
persist.update(conn).await?
431+
}
432+
};
382433

383434
if krate.name != *name {
384435
return Err(bad_request(format_args!(
@@ -407,6 +458,11 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
407458

408459
let edition = edition.map(|edition| edition.as_str());
409460

461+
let published_by = match &auth {
462+
AuthType::Regular(auth) => Some(auth.user().id),
463+
AuthType::Oidc() => None,
464+
};
465+
410466
// Read tarball from request
411467
let hex_cksum: String = Sha256::digest(&tarball_bytes).encode_hex();
412468

@@ -417,7 +473,7 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
417473
// Downcast is okay because the file length must be less than the max upload size
418474
// to get here, and max upload sizes are way less than i32 max
419475
.size(content_length as i32)
420-
.published_by(user.id)
476+
.maybe_published_by(published_by)
421477
.checksum(&hex_cksum)
422478
.maybe_links(package.links.as_deref())
423479
.maybe_rust_version(rust_version.as_deref())
@@ -432,7 +488,7 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
432488
.keywords(&keywords)
433489
.build();
434490

435-
let version = new_version.save(conn, &verified_email_address).await.map_err(|error| {
491+
let version = new_version.save(conn, verified_email_address.as_deref()).await.map_err(|error| {
436492
use diesel::result::{Error, DatabaseErrorKind};
437493
match error {
438494
Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _) =>
@@ -441,14 +497,16 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
441497
}
442498
})?;
443499

444-
NewVersionOwnerAction::builder()
445-
.version_id(version.id)
446-
.user_id(user.id)
447-
.maybe_api_token_id(api_token_id)
448-
.action(VersionAction::Publish)
449-
.build()
450-
.insert(conn)
451-
.await?;
500+
if let AuthType::Regular(auth) = &auth {
501+
NewVersionOwnerAction::builder()
502+
.version_id(version.id)
503+
.user_id(auth.user().id)
504+
.maybe_api_token_id(auth.api_token_id())
505+
.action(VersionAction::Publish)
506+
.build()
507+
.insert(conn)
508+
.await?;
509+
}
452510

453511
// Link this new version to all dependencies
454512
add_dependencies(conn, &deps, version.id).await?;

src/tests/krate/publish/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,5 @@ mod readme;
1919
mod similar_names;
2020
mod tarball;
2121
mod timestamps;
22+
mod trustpub;
2223
mod validation;

src/tests/krate/publish/trustpub.rs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
use crate::tests::builders::PublishBuilder;
2+
use crate::tests::util::{MockTokenUser, RequestHelper, TestApp};
3+
use crates_io_github::{GitHubUser, MockGitHubClient};
4+
use crates_io_trustpub::github::GITHUB_ISSUER_URL;
5+
use crates_io_trustpub::github::test_helpers::FullGitHubClaims;
6+
use crates_io_trustpub::keystore::MockOidcKeyStore;
7+
use crates_io_trustpub::test_keys::encode_for_testing;
8+
use http::StatusCode;
9+
use insta::assert_json_snapshot;
10+
use mockall::predicate::*;
11+
use serde_json::json;
12+
13+
/// Test the full flow of publishing a crate with OIDC authentication
14+
/// (aka. "Trusted Publishing")
15+
///
16+
/// This test will:
17+
///
18+
/// 1. Publish a new crate via API token.
19+
/// 2. Create a Trusted Publishing configuration.
20+
/// 3. Generate a new OIDC token and exchange it for a temporary access token.
21+
/// 4. Publish a new version of the crate using the temporary access token.
22+
#[tokio::test(flavor = "multi_thread")]
23+
async fn test_full_flow() -> anyhow::Result<()> {
24+
const CRATE_NAME: &str = "foo";
25+
26+
const OWNER_NAME: &str = "rust-lang";
27+
const OWNER_ID: i32 = 42;
28+
const REPOSITORY_NAME: &str = "foo-rs";
29+
const WORKFLOW_FILENAME: &str = "publish.yml";
30+
31+
let mut github_mock = MockGitHubClient::new();
32+
33+
github_mock
34+
.expect_get_user()
35+
.with(eq(OWNER_NAME), always())
36+
.returning(|_, _| {
37+
Ok(GitHubUser {
38+
avatar_url: None,
39+
email: None,
40+
id: OWNER_ID,
41+
login: OWNER_NAME.into(),
42+
name: None,
43+
})
44+
});
45+
46+
let (app, client, cookie_client, api_token_client) = TestApp::full()
47+
.with_github(github_mock)
48+
.with_oidc_keystore(GITHUB_ISSUER_URL, MockOidcKeyStore::with_test_key())
49+
.with_token()
50+
.await;
51+
52+
// Step 1: Publish a new crate via API token
53+
54+
let pb = PublishBuilder::new(CRATE_NAME, "1.0.0");
55+
let response = api_token_client.publish_crate(pb).await;
56+
assert_eq!(response.status(), StatusCode::OK);
57+
58+
// Step 2: Create a Trusted Publishing configuration
59+
60+
let body = serde_json::to_vec(&json!({
61+
"github_config": {
62+
"crate": CRATE_NAME,
63+
"repository_owner": OWNER_NAME,
64+
"repository_owner_id": null,
65+
"repository_name": REPOSITORY_NAME,
66+
"workflow_filename": WORKFLOW_FILENAME,
67+
"environment": null,
68+
}
69+
}))?;
70+
71+
let url = "/api/v1/trusted_publishing/github_configs";
72+
let response = cookie_client.put::<()>(url, body).await;
73+
74+
assert_json_snapshot!(response.json(), { ".github_config.created_at" => "[datetime]" }, @r#"
75+
{
76+
"github_config": {
77+
"crate": "foo",
78+
"created_at": "[datetime]",
79+
"environment": null,
80+
"id": 1,
81+
"repository_name": "foo-rs",
82+
"repository_owner": "rust-lang",
83+
"repository_owner_id": 42,
84+
"workflow_filename": "publish.yml"
85+
}
86+
}
87+
"#);
88+
89+
assert_eq!(response.status(), StatusCode::OK);
90+
91+
// Step 3: Generate a new OIDC token and exchange it for a temporary access token
92+
93+
let claims = FullGitHubClaims::builder()
94+
.owner_id(OWNER_ID)
95+
.owner_name(OWNER_NAME)
96+
.repository_name(REPOSITORY_NAME)
97+
.workflow_filename(WORKFLOW_FILENAME)
98+
.build();
99+
100+
let jwt = encode_for_testing(&claims)?;
101+
102+
let body = serde_json::to_vec(&json!({ "jwt": jwt }))?;
103+
let response = client
104+
.put::<()>("/api/v1/trusted_publishing/tokens", body)
105+
.await;
106+
let json = response.json();
107+
assert_json_snapshot!(json, { ".token" => "[token]" }, @r#"
108+
{
109+
"token": "[token]"
110+
}
111+
"#);
112+
assert_eq!(response.status(), StatusCode::OK);
113+
let token = json["token"].as_str().unwrap_or_default();
114+
115+
// Step 4: Publish a new version of the crate using the temporary access token
116+
117+
let oidc_token_client = MockTokenUser::for_token(token, app);
118+
119+
let pb = PublishBuilder::new(CRATE_NAME, "1.1.0");
120+
let response = oidc_token_client.publish_crate(pb).await;
121+
assert_eq!(response.status(), StatusCode::OK);
122+
123+
// Step 5: Revoke the temporary access token
124+
125+
let response = oidc_token_client
126+
.delete::<()>("/api/v1/trusted_publishing/tokens")
127+
.await;
128+
assert_eq!(response.status(), StatusCode::NO_CONTENT);
129+
130+
Ok(())
131+
}

0 commit comments

Comments
 (0)