Skip to content

Commit a9cde09

Browse files
committed
Add DELETE /api/v1/trusted_publishing/tokens API endpoint
1 parent cb12d4c commit a9cde09

File tree

5 files changed

+187
-1
lines changed

5 files changed

+187
-1
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
pub mod exchange;
22
pub mod json;
3+
pub mod revoke;
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
use crate::app::AppState;
2+
use crate::util::errors::{AppResult, custom};
3+
use crates_io_database::schema::trustpub_tokens;
4+
use crates_io_trustpub::access_token::AccessToken;
5+
use diesel::prelude::*;
6+
use diesel_async::RunQueryDsl;
7+
use http::{HeaderMap, StatusCode, header};
8+
9+
#[cfg(test)]
10+
mod tests;
11+
12+
/// Revoke a temporary access token.
13+
///
14+
/// The access token is expected to be passed in the `Authorization` header
15+
/// as a `Bearer` token, similar to how it is used in the publish endpoint.
16+
#[utoipa::path(
17+
delete,
18+
path = "/api/v1/trusted_publishing/tokens",
19+
tag = "trusted_publishing",
20+
responses((status = 204, description = "Successful Response")),
21+
)]
22+
pub async fn revoke_trustpub_token(app: AppState, headers: HeaderMap) -> AppResult<StatusCode> {
23+
let Some(auth_header) = headers.get(header::AUTHORIZATION) else {
24+
let message = "Missing authorization header";
25+
return Err(custom(StatusCode::UNAUTHORIZED, message));
26+
};
27+
28+
let Some(bearer) = auth_header.as_bytes().strip_prefix(b"Bearer ") else {
29+
let message = "Invalid authorization header";
30+
return Err(custom(StatusCode::UNAUTHORIZED, message));
31+
};
32+
33+
let Ok(token) = AccessToken::from_byte_str(bearer) else {
34+
let message = "Invalid authorization header";
35+
return Err(custom(StatusCode::UNAUTHORIZED, message));
36+
};
37+
38+
let hashed_token = token.sha256();
39+
40+
let mut conn = app.db_write().await?;
41+
42+
diesel::delete(trustpub_tokens::table)
43+
.filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice()))
44+
.execute(&mut conn)
45+
.await?;
46+
47+
Ok(StatusCode::NO_CONTENT)
48+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
use crate::tests::util::{MockTokenUser, RequestHelper, TestApp};
2+
use chrono::{TimeDelta, Utc};
3+
use crates_io_database::models::trustpub::NewToken;
4+
use crates_io_database::schema::trustpub_tokens;
5+
use crates_io_trustpub::access_token::AccessToken;
6+
use diesel::prelude::*;
7+
use diesel_async::{AsyncPgConnection, RunQueryDsl};
8+
use http::StatusCode;
9+
use insta::assert_compact_debug_snapshot;
10+
use insta::assert_snapshot;
11+
use secrecy::ExposeSecret;
12+
use sha2::Sha256;
13+
use sha2::digest::Output;
14+
15+
const URL: &str = "/api/v1/trusted_publishing/tokens";
16+
17+
fn generate_token() -> (String, Output<Sha256>) {
18+
let token = AccessToken::generate();
19+
(token.finalize().expose_secret().to_string(), token.sha256())
20+
}
21+
22+
async fn new_token(conn: &mut AsyncPgConnection, crate_id: i32) -> QueryResult<String> {
23+
let (token, hashed_token) = generate_token();
24+
25+
let new_token = NewToken {
26+
expires_at: Utc::now() + TimeDelta::minutes(30),
27+
hashed_token: hashed_token.as_slice(),
28+
crate_ids: &[crate_id],
29+
};
30+
31+
new_token.insert(conn).await?;
32+
33+
Ok(token)
34+
}
35+
36+
async fn all_crate_ids(conn: &mut AsyncPgConnection) -> QueryResult<Vec<Vec<Option<i32>>>> {
37+
trustpub_tokens::table
38+
.select(trustpub_tokens::crate_ids)
39+
.load(conn)
40+
.await
41+
}
42+
43+
#[tokio::test(flavor = "multi_thread")]
44+
async fn test_happy_path() -> anyhow::Result<()> {
45+
let (app, _client) = TestApp::full().empty().await;
46+
let mut conn = app.db_conn().await;
47+
48+
let token1 = new_token(&mut conn, 1).await?;
49+
let _token2 = new_token(&mut conn, 2).await?;
50+
assert_compact_debug_snapshot!(all_crate_ids(&mut conn).await?, @"[[Some(1)], [Some(2)]]");
51+
52+
let header = format!("Bearer {}", token1);
53+
let token_client = MockTokenUser::with_auth_header(header, app.clone());
54+
55+
let response = token_client.delete::<()>(URL).await;
56+
assert_eq!(response.status(), StatusCode::NO_CONTENT);
57+
assert_eq!(response.text(), "");
58+
59+
// Check that the token is deleted
60+
assert_compact_debug_snapshot!(all_crate_ids(&mut conn).await?, @"[[Some(2)]]");
61+
62+
Ok(())
63+
}
64+
65+
#[tokio::test(flavor = "multi_thread")]
66+
async fn test_missing_authorization_header() -> anyhow::Result<()> {
67+
let (_app, client) = TestApp::full().empty().await;
68+
69+
let response = client.delete::<()>(URL).await;
70+
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
71+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Missing authorization header"}]}"#);
72+
73+
Ok(())
74+
}
75+
76+
#[tokio::test(flavor = "multi_thread")]
77+
async fn test_invalid_authorization_header_format() -> anyhow::Result<()> {
78+
let (app, _client) = TestApp::full().empty().await;
79+
80+
// Create a client with an invalid authorization header (missing "Bearer " prefix)
81+
let header = "invalid-format".to_string();
82+
let token_client = MockTokenUser::with_auth_header(header, app.clone());
83+
84+
let response = token_client.delete::<()>(URL).await;
85+
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
86+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Invalid authorization header"}]}"#);
87+
88+
Ok(())
89+
}
90+
91+
#[tokio::test(flavor = "multi_thread")]
92+
async fn test_invalid_token_format() -> anyhow::Result<()> {
93+
let (app, _client) = TestApp::full().empty().await;
94+
95+
// Create a client with an invalid token format
96+
let header = "Bearer invalid-token".to_string();
97+
let token_client = MockTokenUser::with_auth_header(header, app.clone());
98+
99+
let response = token_client.delete::<()>(URL).await;
100+
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
101+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Invalid authorization header"}]}"#);
102+
103+
Ok(())
104+
}
105+
106+
#[tokio::test(flavor = "multi_thread")]
107+
async fn test_non_existent_token() -> anyhow::Result<()> {
108+
let (app, _client) = TestApp::full().empty().await;
109+
110+
// Generate a valid token format, but it doesn't exist in the database
111+
let (token, _) = generate_token();
112+
let header = format!("Bearer {}", token);
113+
let token_client = MockTokenUser::with_auth_header(header, app.clone());
114+
115+
// The request should succeed with 204 No Content even though the token doesn't exist
116+
let response = token_client.delete::<()>(URL).await;
117+
assert_eq!(response.status(), StatusCode::NO_CONTENT);
118+
assert_eq!(response.text(), "");
119+
120+
Ok(())
121+
}

src/router.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,10 @@ pub fn build_axum_router(state: AppState) -> Router<()> {
8989
.routes(routes!(session::authorize_session))
9090
.routes(routes!(session::end_session))
9191
// OIDC / Trusted Publishing
92-
.routes(routes!(trustpub::tokens::exchange::exchange_trustpub_token))
92+
.routes(routes!(
93+
trustpub::tokens::exchange::exchange_trustpub_token,
94+
trustpub::tokens::revoke::revoke_trustpub_token
95+
))
9396
.routes(routes!(
9497
trustpub::github_configs::create::create_trustpub_github_config,
9598
trustpub::github_configs::delete::delete_trustpub_github_config,

src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4302,6 +4302,19 @@ expression: response.json()
43024302
}
43034303
},
43044304
"/api/v1/trusted_publishing/tokens": {
4305+
"delete": {
4306+
"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.",
4307+
"operationId": "revoke_trustpub_token",
4308+
"responses": {
4309+
"204": {
4310+
"description": "Successful Response"
4311+
}
4312+
},
4313+
"summary": "Revoke a temporary access token.",
4314+
"tags": [
4315+
"trusted_publishing"
4316+
]
4317+
},
43054318
"put": {
43064319
"operationId": "exchange_trustpub_token",
43074320
"requestBody": {

0 commit comments

Comments
 (0)