diff --git a/src/controllers/trustpub/tokens/mod.rs b/src/controllers/trustpub/tokens/mod.rs index 4f561da4247..193b4215d11 100644 --- a/src/controllers/trustpub/tokens/mod.rs +++ b/src/controllers/trustpub/tokens/mod.rs @@ -1,2 +1,3 @@ pub mod exchange; pub mod json; +pub mod revoke; diff --git a/src/controllers/trustpub/tokens/revoke/mod.rs b/src/controllers/trustpub/tokens/revoke/mod.rs new file mode 100644 index 00000000000..0ffe9f6a7ee --- /dev/null +++ b/src/controllers/trustpub/tokens/revoke/mod.rs @@ -0,0 +1,48 @@ +use crate::app::AppState; +use crate::util::errors::{AppResult, custom}; +use crates_io_database::schema::trustpub_tokens; +use crates_io_trustpub::access_token::AccessToken; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use http::{HeaderMap, StatusCode, header}; + +#[cfg(test)] +mod tests; + +/// Revoke a temporary access token. +/// +/// The access token is expected to be passed in the `Authorization` header +/// as a `Bearer` token, similar to how it is used in the publish endpoint. +#[utoipa::path( + delete, + path = "/api/v1/trusted_publishing/tokens", + tag = "trusted_publishing", + responses((status = 204, description = "Successful Response")), +)] +pub async fn revoke_trustpub_token(app: AppState, headers: HeaderMap) -> AppResult { + let Some(auth_header) = headers.get(header::AUTHORIZATION) else { + let message = "Missing authorization header"; + return Err(custom(StatusCode::UNAUTHORIZED, message)); + }; + + let Some(bearer) = auth_header.as_bytes().strip_prefix(b"Bearer ") else { + let message = "Invalid authorization header"; + return Err(custom(StatusCode::UNAUTHORIZED, message)); + }; + + let Ok(token) = AccessToken::from_byte_str(bearer) else { + let message = "Invalid authorization header"; + return Err(custom(StatusCode::UNAUTHORIZED, message)); + }; + + let hashed_token = token.sha256(); + + let mut conn = app.db_write().await?; + + diesel::delete(trustpub_tokens::table) + .filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice())) + .execute(&mut conn) + .await?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/controllers/trustpub/tokens/revoke/tests.rs b/src/controllers/trustpub/tokens/revoke/tests.rs new file mode 100644 index 00000000000..fb58df72b5b --- /dev/null +++ b/src/controllers/trustpub/tokens/revoke/tests.rs @@ -0,0 +1,121 @@ +use crate::tests::util::{MockTokenUser, RequestHelper, TestApp}; +use chrono::{TimeDelta, Utc}; +use crates_io_database::models::trustpub::NewToken; +use crates_io_database::schema::trustpub_tokens; +use crates_io_trustpub::access_token::AccessToken; +use diesel::prelude::*; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; +use http::StatusCode; +use insta::assert_compact_debug_snapshot; +use insta::assert_snapshot; +use secrecy::ExposeSecret; +use sha2::Sha256; +use sha2::digest::Output; + +const URL: &str = "/api/v1/trusted_publishing/tokens"; + +fn generate_token() -> (String, Output) { + let token = AccessToken::generate(); + (token.finalize().expose_secret().to_string(), token.sha256()) +} + +async fn new_token(conn: &mut AsyncPgConnection, crate_id: i32) -> QueryResult { + let (token, hashed_token) = generate_token(); + + let new_token = NewToken { + expires_at: Utc::now() + TimeDelta::minutes(30), + hashed_token: hashed_token.as_slice(), + crate_ids: &[crate_id], + }; + + new_token.insert(conn).await?; + + Ok(token) +} + +async fn all_crate_ids(conn: &mut AsyncPgConnection) -> QueryResult>>> { + trustpub_tokens::table + .select(trustpub_tokens::crate_ids) + .load(conn) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_happy_path() -> anyhow::Result<()> { + let (app, _client) = TestApp::full().empty().await; + let mut conn = app.db_conn().await; + + let token1 = new_token(&mut conn, 1).await?; + let _token2 = new_token(&mut conn, 2).await?; + assert_compact_debug_snapshot!(all_crate_ids(&mut conn).await?, @"[[Some(1)], [Some(2)]]"); + + let header = format!("Bearer {}", token1); + let token_client = MockTokenUser::with_auth_header(header, app.clone()); + + let response = token_client.delete::<()>(URL).await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_eq!(response.text(), ""); + + // Check that the token is deleted + assert_compact_debug_snapshot!(all_crate_ids(&mut conn).await?, @"[[Some(2)]]"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_missing_authorization_header() -> anyhow::Result<()> { + let (_app, client) = TestApp::full().empty().await; + + let response = client.delete::<()>(URL).await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Missing authorization header"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_authorization_header_format() -> anyhow::Result<()> { + let (app, _client) = TestApp::full().empty().await; + + // Create a client with an invalid authorization header (missing "Bearer " prefix) + let header = "invalid-format".to_string(); + let token_client = MockTokenUser::with_auth_header(header, app.clone()); + + let response = token_client.delete::<()>(URL).await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Invalid authorization header"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_token_format() -> anyhow::Result<()> { + let (app, _client) = TestApp::full().empty().await; + + // Create a client with an invalid token format + let header = "Bearer invalid-token".to_string(); + let token_client = MockTokenUser::with_auth_header(header, app.clone()); + + let response = token_client.delete::<()>(URL).await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Invalid authorization header"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_non_existent_token() -> anyhow::Result<()> { + let (app, _client) = TestApp::full().empty().await; + + // Generate a valid token format, but it doesn't exist in the database + let (token, _) = generate_token(); + let header = format!("Bearer {}", token); + let token_client = MockTokenUser::with_auth_header(header, app.clone()); + + // The request should succeed with 204 No Content even though the token doesn't exist + let response = token_client.delete::<()>(URL).await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_eq!(response.text(), ""); + + Ok(()) +} diff --git a/src/router.rs b/src/router.rs index bc6842c3245..254c5e8ced3 100644 --- a/src/router.rs +++ b/src/router.rs @@ -89,7 +89,10 @@ pub fn build_axum_router(state: AppState) -> Router<()> { .routes(routes!(session::authorize_session)) .routes(routes!(session::end_session)) // OIDC / Trusted Publishing - .routes(routes!(trustpub::tokens::exchange::exchange_trustpub_token)) + .routes(routes!( + trustpub::tokens::exchange::exchange_trustpub_token, + trustpub::tokens::revoke::revoke_trustpub_token + )) .routes(routes!( trustpub::github_configs::create::create_trustpub_github_config, trustpub::github_configs::delete::delete_trustpub_github_config, diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap index 28358b68d7e..37e627a448b 100644 --- a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap +++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap @@ -4302,6 +4302,19 @@ expression: response.json() } }, "/api/v1/trusted_publishing/tokens": { + "delete": { + "description": "The access token is expected to be passed in the `Authorization` header\nas a `Bearer` token, similar to how it is used in the publish endpoint.", + "operationId": "revoke_trustpub_token", + "responses": { + "204": { + "description": "Successful Response" + } + }, + "summary": "Revoke a temporary access token.", + "tags": [ + "trusted_publishing" + ] + }, "put": { "operationId": "exchange_trustpub_token", "requestBody": { diff --git a/src/tests/util.rs b/src/tests/util.rs index 395b34f133c..c6f4c3766fc 100644 --- a/src/tests/util.rs +++ b/src/tests/util.rs @@ -344,7 +344,7 @@ impl MockCookieUser { MockTokenUser { app: self.app.clone(), - token, + token: Some(token), plaintext: plaintext.expose_secret().into(), } } @@ -353,7 +353,7 @@ impl MockCookieUser { /// A type that can generate token authenticated requests pub struct MockTokenUser { app: TestApp, - token: ApiToken, + token: Option, plaintext: String, } @@ -370,9 +370,18 @@ impl RequestHelper for MockTokenUser { } impl MockTokenUser { + pub fn with_auth_header(token: String, app: TestApp) -> Self { + Self { + app, + token: None, + plaintext: token, + } + } + /// Returns a reference to the database `ApiToken` model pub fn as_model(&self) -> &ApiToken { - &self.token + const ERROR: &str = "Original `ApiToken` was not set on this `MockTokenUser` instance"; + self.token.as_ref().expect(ERROR) } pub fn plaintext(&self) -> &str {