Skip to content

Commit 4006490

Browse files
committed
Implement PUT /api/v1/trusted_publishing/tokens API endpoint
1 parent a60534f commit 4006490

File tree

9 files changed

+590
-0
lines changed

9 files changed

+590
-0
lines changed

Cargo.lock

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+2
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,12 @@ crates_io_index = { path = "crates/crates_io_index", features = ["testing"] }
143143
crates_io_tarball = { path = "crates/crates_io_tarball", features = ["builder"] }
144144
crates_io_team_repo = { path = "crates/crates_io_team_repo", features = ["mock"] }
145145
crates_io_test_db = { path = "crates/crates_io_test_db" }
146+
crates_io_trustpub = { path = "crates/crates_io_trustpub", features = ["test-helpers"] }
146147
claims = "=0.8.0"
147148
diesel = { version = "=2.2.10", features = ["r2d2"] }
148149
googletest = "=0.14.0"
149150
insta = { version = "=1.43.1", features = ["glob", "json", "redactions"] }
151+
jsonwebtoken = "=9.3.1"
150152
regex = "=1.11.1"
151153
sentry = { version = "=0.37.0", features = ["test"] }
152154
tokio = "=1.45.0"

src/controllers/trustpub/mod.rs

+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
pub mod github_configs;
2+
pub mod tokens;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
use super::json;
2+
use crate::app::AppState;
3+
use crate::util::errors::{AppResult, bad_request, server_error};
4+
use axum::Json;
5+
use crates_io_database::models::trustpub::{NewToken, NewUsedJti};
6+
use crates_io_database::schema::trustpub_configs_github;
7+
use crates_io_diesel_helpers::lower;
8+
use crates_io_trustpub::access_token::AccessToken;
9+
use crates_io_trustpub::github::{GITHUB_ISSUER_URL, GitHubClaims};
10+
use crates_io_trustpub::unverified::UnverifiedClaims;
11+
use diesel::prelude::*;
12+
use diesel::result::DatabaseErrorKind::UniqueViolation;
13+
use diesel::result::Error::DatabaseError;
14+
use diesel_async::scoped_futures::ScopedFutureExt;
15+
use diesel_async::{AsyncConnection, RunQueryDsl};
16+
use secrecy::ExposeSecret;
17+
18+
#[cfg(test)]
19+
mod tests;
20+
21+
/// Exchange an OIDC token for a temporary access token.
22+
#[utoipa::path(
23+
put,
24+
path = "/api/v1/trusted_publishing/tokens",
25+
request_body = inline(json::ExchangeRequest),
26+
tag = "trusted_publishing",
27+
responses((status = 200, description = "Successful Response", body = inline(json::ExchangeResponse))),
28+
)]
29+
pub async fn exchange_trustpub_token(
30+
state: AppState,
31+
json: json::ExchangeRequest,
32+
) -> AppResult<Json<json::ExchangeResponse>> {
33+
let unverified_jwt = json.jwt;
34+
35+
let unverified_token_data = UnverifiedClaims::decode(&unverified_jwt)
36+
.map_err(|_err| bad_request("Failed to decode JWT"))?;
37+
38+
let unverified_issuer = unverified_token_data.claims.iss;
39+
let Some(keystore) = state.oidc_key_stores.get(&unverified_issuer) else {
40+
return Err(bad_request("Unsupported JWT issuer"));
41+
};
42+
43+
let Some(unverified_key_id) = unverified_token_data.header.kid else {
44+
let message = "Missing JWT key ID";
45+
return Err(bad_request(message));
46+
};
47+
48+
let key = match keystore.get_oidc_key(&unverified_key_id).await {
49+
Ok(Some(key)) => key,
50+
Ok(None) => {
51+
return Err(bad_request("Invalid JWT key ID"));
52+
}
53+
Err(err) => {
54+
warn!("Failed to load OIDC key set: {err}");
55+
return Err(server_error("Failed to load OIDC key set"));
56+
}
57+
};
58+
59+
// The following code is only supporting GitHub Actions for now, so let's
60+
// drop out if the issuer is not GitHub.
61+
if unverified_issuer != GITHUB_ISSUER_URL {
62+
return Err(bad_request("Unsupported JWT issuer"));
63+
}
64+
65+
let audience = &state.config.trustpub_audience;
66+
let signed_claims = GitHubClaims::decode(&unverified_jwt, audience, &key).map_err(|err| {
67+
warn!("Failed to decode JWT: {err}");
68+
bad_request("Failed to decode JWT")
69+
})?;
70+
71+
let mut conn = state.db_write().await?;
72+
73+
conn.transaction(|conn| {
74+
async move {
75+
let used_jti = NewUsedJti::new(&signed_claims.jti, signed_claims.exp);
76+
match used_jti.insert(conn).await {
77+
Ok(_) => {} // JTI was successfully inserted, continue
78+
Err(DatabaseError(UniqueViolation, _)) => {
79+
warn!("Attempted JWT reuse (jti: {})", signed_claims.jti);
80+
let detail = "JWT has already been used";
81+
return Err(bad_request(detail));
82+
}
83+
Err(err) => Err(err)?,
84+
};
85+
86+
let repo = &signed_claims.repository;
87+
let Some((repository_owner, repository_name)) = repo.split_once('/') else {
88+
warn!("Unexpected repository format in JWT: {repo}");
89+
let message = "Unexpected `repository` value";
90+
return Err(bad_request(message));
91+
};
92+
93+
let Some(workflow_filename) = signed_claims.workflow_filename() else {
94+
let job_workflow_ref = &signed_claims.job_workflow_ref;
95+
warn!("Unexpected `job_workflow_ref` format in JWT: {job_workflow_ref}");
96+
let message = "Unexpected `job_workflow_ref` value";
97+
return Err(bad_request(message));
98+
};
99+
100+
let Ok(repository_owner_id) = signed_claims.repository_owner_id.parse::<i32>() else {
101+
let repository_owner_id = &signed_claims.repository_owner_id;
102+
warn!("Unexpected `repository_owner_id` format in JWT: {repository_owner_id}");
103+
let message = "Unexpected `repository_owner_id` value";
104+
return Err(bad_request(message));
105+
};
106+
107+
let crate_ids = trustpub_configs_github::table
108+
.select(trustpub_configs_github::crate_id)
109+
.filter(trustpub_configs_github::repository_owner_id.eq(&repository_owner_id))
110+
.filter(
111+
lower(trustpub_configs_github::repository_owner).eq(lower(&repository_owner)),
112+
)
113+
.filter(lower(trustpub_configs_github::repository_name).eq(lower(&repository_name)))
114+
.filter(trustpub_configs_github::workflow_filename.eq(&workflow_filename))
115+
.filter(
116+
trustpub_configs_github::environment
117+
.is_null()
118+
.or(lower(trustpub_configs_github::environment)
119+
.eq(lower(&signed_claims.environment))),
120+
)
121+
.load::<i32>(conn)
122+
.await?;
123+
124+
if crate_ids.is_empty() {
125+
warn!("No matching Trusted Publishing config found");
126+
let message = "No matching Trusted Publishing config found";
127+
return Err(bad_request(message));
128+
}
129+
130+
let new_token = AccessToken::generate();
131+
132+
let new_token_model = NewToken {
133+
expires_at: chrono::Utc::now() + chrono::Duration::minutes(30),
134+
hashed_token: &new_token.sha256(),
135+
crate_ids: &crate_ids,
136+
};
137+
138+
new_token_model.insert(conn).await?;
139+
140+
let token = new_token.expose_secret().into();
141+
Ok(Json(json::ExchangeResponse { token }))
142+
}
143+
.scope_boxed()
144+
})
145+
.await
146+
}

0 commit comments

Comments
 (0)