Skip to content

Commit 986a71d

Browse files
committed
WIP
1 parent 106ef4d commit 986a71d

File tree

8 files changed

+293
-48
lines changed

8 files changed

+293
-48
lines changed

Cargo.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ paste = "=1.0.15"
110110
postgres-native-tls = "=0.5.1"
111111
prometheus = { version = "=0.14.0", default-features = false }
112112
rand = "=0.9.1"
113+
regex = "=1.11.1"
113114
reqwest = { version = "=0.12.15", features = ["gzip", "json"] }
114115
rss = { version = "=2.0.12", default-features = false, features = ["atom"] }
115116
secrecy = "=0.10.3"
@@ -149,7 +150,6 @@ diesel = { version = "=2.2.10", features = ["r2d2"] }
149150
googletest = "=0.14.0"
150151
insta = { version = "=1.43.1", features = ["glob", "json", "redactions"] }
151152
jsonwebtoken = "=9.3.1"
152-
regex = "=1.11.1"
153153
sentry = { version = "=0.37.0", features = ["test"] }
154154
tokio = "=1.45.0"
155155
zip = { version = "=2.6.1", default-features = false, features = ["deflate"] }

src/controllers/krate/publish.rs

+101-46
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,63 @@ 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+
.and_then(AccessToken::from_bytes);
144+
145+
let auth = match (access_token, &existing_crate) {
146+
(Some(access_token), Some(existing_crate)) => {
147+
let hashed_token = access_token.sha256();
148+
149+
trustpub_tokens::table
150+
.filter(trustpub_tokens::crate_ids.contains(vec![existing_crate.id]))
151+
.filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice()))
152+
.filter(trustpub_tokens::expires_at.gt(now))
153+
.select(trustpub_tokens::id)
154+
.get_result::<i64>(&mut conn)
155+
.await?;
156+
157+
AuthType::Oidc()
158+
}
159+
_ => {
160+
let auth = AuthCheck::default()
161+
.with_endpoint_scope(endpoint_scope)
162+
.for_crate(&metadata.name)
163+
.check(&req, &mut conn)
164+
.await?;
165+
166+
AuthType::Regular(Box::new(auth))
167+
}
168+
};
138169

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. \
170+
let verified_email_address = match &auth {
171+
AuthType::Regular(auth) => {
172+
let verified_email_address = auth.user().verified_email(&mut conn).await?;
173+
let verified_email_address = verified_email_address.ok_or_else(|| {
174+
bad_request(format!(
175+
"A verified email address is required to publish crates to crates.io. \
143176
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,
177+
app.config.domain_name,
178+
))
179+
})?;
180+
Some(verified_email_address)
181+
}
182+
AuthType::Oidc() => None,
152183
};
153184

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

158197
let max_upload_size = existing_crate
159198
.as_ref()
@@ -342,9 +381,6 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
342381
validate_dependency(dep)?;
343382
}
344383

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

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-
};
407+
let krate = match &auth {
408+
AuthType::Regular(auth) => {
409+
let user = auth.user();
377410

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

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

408456
let edition = edition.map(|edition| edition.as_str());
409457

458+
let published_by = match &auth {
459+
AuthType::Regular(auth) => Some(auth.user().id),
460+
AuthType::Oidc() => None,
461+
};
462+
410463
// Read tarball from request
411464
let hex_cksum: String = Sha256::digest(&tarball_bytes).encode_hex();
412465

@@ -417,7 +470,7 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
417470
// Downcast is okay because the file length must be less than the max upload size
418471
// to get here, and max upload sizes are way less than i32 max
419472
.size(content_length as i32)
420-
.published_by(user.id)
473+
.maybe_published_by(published_by)
421474
.checksum(&hex_cksum)
422475
.maybe_links(package.links.as_deref())
423476
.maybe_rust_version(rust_version.as_deref())
@@ -432,7 +485,7 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
432485
.keywords(&keywords)
433486
.build();
434487

435-
let version = new_version.save(conn, &verified_email_address).await.map_err(|error| {
488+
let version = new_version.save(conn, verified_email_address.as_deref()).await.map_err(|error| {
436489
use diesel::result::{Error, DatabaseErrorKind};
437490
match error {
438491
Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _) =>
@@ -441,14 +494,16 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
441494
}
442495
})?;
443496

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?;
497+
if let AuthType::Regular(auth) = &auth {
498+
NewVersionOwnerAction::builder()
499+
.version_id(version.id)
500+
.user_id(auth.user().id)
501+
.maybe_api_token_id(auth.api_token_id())
502+
.action(VersionAction::Publish)
503+
.build()
504+
.insert(conn)
505+
.await?;
506+
}
452507

453508
// Link this new version to all dependencies
454509
add_dependencies(conn, &deps, version.id).await?;
+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
pub mod exchange;
22
pub mod json;
3+
pub mod revoke;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
use crate::app::AppState;
2+
use crate::util::errors::{AppResult, custom};
3+
use crates_io_database::schema::trustpub_tokens;
4+
use diesel::prelude::*;
5+
use diesel_async::RunQueryDsl;
6+
use http::{HeaderMap, StatusCode, header};
7+
use sha2::{Digest, Sha256};
8+
9+
/// Revoke a temporary access token.
10+
#[utoipa::path(
11+
delete,
12+
path = "/api/v1/trusted_publishing/tokens",
13+
tag = "trusted_publishing",
14+
responses((status = 204, description = "Successful Response")),
15+
)]
16+
pub async fn revoke_trustpub_token(app: AppState, headers: HeaderMap) -> AppResult<StatusCode> {
17+
let Some(auth_header) = headers.get(header::AUTHORIZATION) else {
18+
let message = "Missing authorization header";
19+
return Err(custom(StatusCode::UNAUTHORIZED, message));
20+
};
21+
22+
let Some(bearer) = auth_header.as_bytes().strip_prefix(b"Bearer ") else {
23+
let message = "Invalid authorization header";
24+
return Err(custom(StatusCode::UNAUTHORIZED, message));
25+
};
26+
27+
if !bearer.starts_with(b"crates.io/oidc/") {
28+
let message = "Invalid authorization header";
29+
return Err(custom(StatusCode::UNAUTHORIZED, message));
30+
}
31+
32+
let hashed_token = Sha256::digest(bearer);
33+
34+
let mut conn = app.db_write().await?;
35+
36+
diesel::delete(trustpub_tokens::table)
37+
.filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice()))
38+
.execute(&mut conn)
39+
.await?;
40+
41+
Ok(StatusCode::NO_CONTENT)
42+
}

src/router.rs

+4-1
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,10 @@ pub fn build_axum_router(state: AppState) -> Router<()> {
8888
.routes(routes!(session::authorize_session))
8989
.routes(routes!(session::end_session))
9090
// OIDC / Trusted Publishing
91-
.routes(routes!(trustpub::tokens::exchange::exchange_trustpub_token))
91+
.routes(routes!(
92+
trustpub::tokens::exchange::exchange_trustpub_token,
93+
trustpub::tokens::revoke::revoke_trustpub_token
94+
))
9295
.routes(routes!(
9396
trustpub::github_configs::create::create_trustpub_github_config,
9497
))

src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap

+12
Original file line numberDiff line numberDiff line change
@@ -4185,6 +4185,18 @@ expression: response.json()
41854185
}
41864186
},
41874187
"/api/v1/trusted_publishing/tokens": {
4188+
"delete": {
4189+
"operationId": "revoke_trustpub_token",
4190+
"responses": {
4191+
"204": {
4192+
"description": "Successful Response"
4193+
}
4194+
},
4195+
"summary": "Revoke a temporary access token.",
4196+
"tags": [
4197+
"trusted_publishing"
4198+
]
4199+
},
41884200
"put": {
41894201
"operationId": "exchange_trustpub_token",
41904202
"requestBody": {

src/tests/krate/publish/mod.rs

+1
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;

0 commit comments

Comments
 (0)