|  | 
|  | 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