Skip to content

Add DELETE /api/v1/trusted_publishing/tokens API endpoint #11234

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
536b326
diesel_helpers: Adjust `lower()` fn to also accept `NULL`
Turbo87 May 8, 2025
877f1a2
trustpub: Implement `load_jwks()` fn
Turbo87 May 7, 2025
fd259c3
trustpub: Add `GITHUB_ISSUER_URL` constant
Turbo87 May 7, 2025
1af9085
trustpub: Add `OidcKeyStore` trait
Turbo87 May 7, 2025
496ecb0
trustpub: Implement `OidcKeyStore` trait
Turbo87 May 7, 2025
f82fbe2
trustpub: Add mock `OidcKeyStore` implementation
Turbo87 May 7, 2025
5478718
trustpub: Add RSA keys for testing purposes
Turbo87 May 7, 2025
521967e
trustpub: Add `MockOidcKeyStore::with_test_key()` fn
Turbo87 May 8, 2025
8d4bc30
trustpub: Implement `extract_workflow_filename()` fn
Turbo87 May 7, 2025
32cc9ee
trustpub: Implement `UnverifiedClaims::decode()` fn
Turbo87 May 7, 2025
9f185ef
trustpub: Implement `GitHubClaims` struct
Turbo87 May 7, 2025
80e2a91
trustpub: Implement `FullGitHubClaims` struct for testing purposes
Turbo87 May 8, 2025
a5dc806
trustpub: Implement `AccessToken` struct
Turbo87 May 8, 2025
1d89d2c
config: Add `TRUSTPUB_AUDIENCE` setting
Turbo87 May 8, 2025
3364e0c
App: Add `oidc_key_stores` hashmap
Turbo87 May 7, 2025
6016bac
AppBuilder: Add `trustpub_providers()` fn
Turbo87 May 8, 2025
f2df8db
bin/server: Use `TRUSTPUB_PROVIDERS` env var to configure Trusted Pub…
Turbo87 May 8, 2025
05b75f8
tests/TestAppBuilder: Add `with_oidc_keystore()` fn
Turbo87 May 7, 2025
8f25d27
Implement `PUT /api/v1/trusted_publishing/tokens` API endpoint
Turbo87 May 8, 2025
ce7f98a
tests/util: Change `MockTokenUser::token` to be optional
Turbo87 Apr 16, 2025
cb12d4c
tests/util: Add `MockTokenUser::with_auth_header()` fn
Turbo87 Apr 16, 2025
a9cde09
Add `DELETE /api/v1/trusted_publishing/tokens` API endpoint
Turbo87 May 23, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,12 @@ crates_io_index = { path = "crates/crates_io_index", features = ["testing"] }
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" }
crates_io_trustpub = { path = "crates/crates_io_trustpub", features = ["test-helpers"] }
claims = "=0.8.0"
diesel = { version = "=2.2.10", features = ["r2d2"] }
googletest = "=0.14.0"
insta = { version = "=1.43.1", features = ["glob", "json", "redactions"] }
jsonwebtoken = "=9.3.1"
regex = "=1.11.1"
sentry = { version = "=0.38.1", features = ["test"] }
tokio = "=1.45.1"
Expand Down
3 changes: 2 additions & 1 deletion crates/crates_io_database/src/models/category.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use crate::schema::*;
use chrono::{DateTime, Utc};
use diesel::dsl;
use diesel::prelude::*;
use diesel::sql_types::Text;
use diesel_async::scoped_futures::ScopedFutureExt;
use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl};
use futures_util::FutureExt;
Expand All @@ -19,7 +20,7 @@ pub struct Category {
pub created_at: DateTime<Utc>,
}

type WithSlug<'a> = dsl::Eq<categories::slug, crates_io_diesel_helpers::lower<&'a str>>;
type WithSlug<'a> = dsl::Eq<categories::slug, crates_io_diesel_helpers::lower<Text, &'a str>>;

#[derive(Associations, Insertable, Identifiable, Debug, Clone, Copy)]
#[diesel(
Expand Down
2 changes: 1 addition & 1 deletion crates/crates_io_diesel_helpers/src/fns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use diesel::sql_types::{Date, Double, Integer, Interval, SingleValue, Text, Time
define_sql_function!(#[aggregate] fn array_agg<T: SingleValue>(x: T) -> Array<T>);
define_sql_function!(fn canon_crate_name(x: Text) -> Text);
define_sql_function!(fn to_char(a: Date, b: Text) -> Text);
define_sql_function!(fn lower(x: Text) -> Text);
define_sql_function!(fn lower<T: SingleValue>(x: T) -> T);
define_sql_function!(fn date_part(x: Text, y: Timestamptz) -> Double);
define_sql_function! {
#[sql_name = "date_part"]
Expand Down
22 changes: 21 additions & 1 deletion crates/crates_io_trustpub/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,30 @@ edition = "2024"
[lints]
workspace = true

[features]
test-helpers = ["dep:bon", "dep:mockall", "dep:serde_json"]

[dependencies]
anyhow = "=1.0.98"
async-trait = "=0.1.88"
bon = { version = "=3.6.3", optional = true }
chrono = { version = "=0.4.41", features = ["serde"] }
jsonwebtoken = "=9.3.1"
mockall = { version = "=0.13.1", optional = true }
rand = "=0.9.1"
reqwest = { version = "=0.12.15", features = ["gzip", "json"] }
regex = "=1.11.1"
secrecy = "=0.10.3"
serde = { version = "=1.0.219", features = ["derive"] }
serde_json = { version = "=1.0.140", optional = true }
sha2 = "=0.10.9"
thiserror = "=2.0.12"
tokio = { version = "=1.45.1", features = ["sync"] }

[dev-dependencies]
bon = "=3.6.3"
claims = "=0.8.0"
insta = "=1.43.1"
insta = { version = "=1.43.1", features = ["json", "redactions"] }
mockito = "=1.7.0"
serde_json = "=1.0.140"
tokio = { version = "=1.45.1", features = ["macros", "rt-multi-thread"] }
174 changes: 174 additions & 0 deletions crates/crates_io_trustpub/src/access_token.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
use rand::distr::{Alphanumeric, SampleString};
use secrecy::{ExposeSecret, SecretString};
use sha2::digest::Output;
use sha2::{Digest, Sha256};

/// A temporary access token used to publish crates to crates.io using
/// the "Trusted Publishing" feature.
///
/// The token consists of a prefix, a random alphanumeric string (31 characters),
/// and a single-character checksum.
#[derive(Debug)]
pub struct AccessToken(SecretString);

impl AccessToken {
/// The prefix used for the temporary access token.
///
/// This overlaps with the `cio` prefix used for other tokens, but since
/// the regular tokens don't use `_` characters, they can easily be
/// distinguished.
pub const PREFIX: &str = "cio_tp_";

/// The length of the random alphanumeric string in the token, without
/// the checksum.
const RAW_LENGTH: usize = 31;

/// Generate a new random access token.
pub fn generate() -> Self {
let raw = Alphanumeric.sample_string(&mut rand::rng(), Self::RAW_LENGTH);
Self(raw.into())
}

/// Parse a byte string into an access token.
///
/// This can be used to convert an HTTP header value into an access token.
pub fn from_byte_str(byte_str: &[u8]) -> Result<Self, AccessTokenError> {
let suffix = byte_str
.strip_prefix(Self::PREFIX.as_bytes())
.ok_or(AccessTokenError::MissingPrefix)?;

if suffix.len() != Self::RAW_LENGTH + 1 {
return Err(AccessTokenError::InvalidLength);
}

let suffix = std::str::from_utf8(suffix).map_err(|_| AccessTokenError::InvalidCharacter)?;
if !suffix.chars().all(|c| char::is_ascii_alphanumeric(&c)) {
return Err(AccessTokenError::InvalidCharacter);
}

let raw = suffix.chars().take(Self::RAW_LENGTH).collect::<String>();
let claimed_checksum = suffix.chars().nth(Self::RAW_LENGTH).unwrap();
let actual_checksum = checksum(raw.as_bytes());
if claimed_checksum != actual_checksum {
return Err(AccessTokenError::InvalidChecksum {
claimed: claimed_checksum,
actual: actual_checksum,
});
}

Ok(Self(raw.into()))
}

/// Wrap the raw access token with the token prefix and a checksum.
///
/// This turns e.g. `ABC` into `cio_tp_ABC{checksum}`.
pub fn finalize(&self) -> SecretString {
let raw = self.0.expose_secret();
let checksum = checksum(raw.as_bytes());
format!("{}{raw}{checksum}", Self::PREFIX).into()
}

/// Generate a SHA256 hash of the access token.
///
/// This is used to create a hashed version of the token for storage in
/// the database to avoid storing the plaintext token.
pub fn sha256(&self) -> Output<Sha256> {
Sha256::digest(self.0.expose_secret())
}
}

/// The error type for parsing access tokens.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccessTokenError {
MissingPrefix,
InvalidLength,
InvalidCharacter,
InvalidChecksum { claimed: char, actual: char },
}

/// Generate a single-character checksum for the given raw token.
///
/// Note that this checksum is not cryptographically secure and should not be
/// used for security purposes. It should only be used to detect invalid tokens.
fn checksum(raw: &[u8]) -> char {
const ALPHANUMERIC: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";

let checksum = raw.iter().fold(0, |acc, &b| acc ^ b);

ALPHANUMERIC
.chars()
.nth(checksum as usize % ALPHANUMERIC.len())
.unwrap_or('0')
}

#[cfg(test)]
mod tests {
use super::*;
use claims::{assert_err_eq, assert_ok};
use insta::{assert_compact_debug_snapshot, assert_snapshot};

const EXAMPLE_TOKEN: &str = "gGK6jurSwKyl9V3Az19z7YEFQI9aoOO";

#[test]
fn test_generate() {
let token = AccessToken::generate();
assert_eq!(token.0.expose_secret().len(), AccessToken::RAW_LENGTH);
}

#[test]
fn test_finalize() {
let token = AccessToken(SecretString::from(EXAMPLE_TOKEN));
assert_snapshot!(token.finalize().expose_secret(), @"cio_tp_gGK6jurSwKyl9V3Az19z7YEFQI9aoOOd");
}

#[test]
fn test_sha256() {
let token = AccessToken(SecretString::from(EXAMPLE_TOKEN));
let hash = token.sha256();
assert_compact_debug_snapshot!(hash.as_slice(), @"[11, 102, 58, 175, 81, 174, 38, 227, 173, 48, 158, 96, 20, 130, 99, 78, 7, 16, 241, 211, 195, 166, 110, 74, 193, 126, 53, 125, 42, 21, 23, 124]");
}

#[test]
fn test_from_byte_str() {
let token = AccessToken::generate().finalize();
let token = token.expose_secret();
let token2 = assert_ok!(AccessToken::from_byte_str(token.as_bytes()));
assert_eq!(token2.finalize().expose_secret(), token);

let bytes = b"cio_tp_0000000000000000000000000000000w";
assert_ok!(AccessToken::from_byte_str(bytes));

let bytes = b"invalid_token";
assert_err_eq!(
AccessToken::from_byte_str(bytes),
AccessTokenError::MissingPrefix
);

let bytes = b"cio_tp_invalid_token";
assert_err_eq!(
AccessToken::from_byte_str(bytes),
AccessTokenError::InvalidLength
);

let bytes = b"cio_tp_00000000000000000000000000";
assert_err_eq!(
AccessToken::from_byte_str(bytes),
AccessTokenError::InvalidLength
);

let bytes = b"cio_tp_000000@0000000000000000000000000";
assert_err_eq!(
AccessToken::from_byte_str(bytes),
AccessTokenError::InvalidCharacter
);

let bytes = b"cio_tp_00000000000000000000000000000000";
assert_err_eq!(
AccessToken::from_byte_str(bytes),
AccessTokenError::InvalidChecksum {
claimed: '0',
actual: 'w',
}
);
}
}
Loading