mas_handlers/admin/v1/user_registration_tokens/
revoke.rs

1// Copyright 2025 New Vector Ltd.
2// Copyright 2025 The Matrix.org Foundation C.I.C.
3//
4// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5// Please see LICENSE files in the repository root for full details.
6
7use aide::{OperationIo, transform::TransformOperation};
8use axum::{Json, response::IntoResponse};
9use hyper::StatusCode;
10use mas_axum_utils::record_error;
11use ulid::Ulid;
12
13use crate::{
14    admin::{
15        call_context::CallContext,
16        model::{Resource, UserRegistrationToken},
17        params::UlidPathParam,
18        response::{ErrorResponse, SingleResponse},
19    },
20    impl_from_error_for_route,
21};
22
23#[derive(Debug, thiserror::Error, OperationIo)]
24#[aide(output_with = "Json<ErrorResponse>")]
25pub enum RouteError {
26    #[error(transparent)]
27    Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
28
29    #[error("Registration token with ID {0} not found")]
30    NotFound(Ulid),
31
32    #[error("Registration token with ID {0} is already revoked")]
33    AlreadyRevoked(Ulid),
34}
35
36impl_from_error_for_route!(mas_storage::RepositoryError);
37
38impl IntoResponse for RouteError {
39    fn into_response(self) -> axum::response::Response {
40        let error = ErrorResponse::from_error(&self);
41        let sentry_event_id = record_error!(self, Self::Internal(_));
42        let status = match self {
43            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
44            Self::NotFound(_) => StatusCode::NOT_FOUND,
45            Self::AlreadyRevoked(_) => StatusCode::BAD_REQUEST,
46        };
47        (status, sentry_event_id, Json(error)).into_response()
48    }
49}
50
51pub fn doc(operation: TransformOperation) -> TransformOperation {
52    operation
53        .id("revokeUserRegistrationToken")
54        .summary("Revoke a user registration token")
55        .description("Calling this endpoint will revoke the user registration token, preventing it from being used for new registrations.")
56        .tag("user-registration-token")
57        .response_with::<200, Json<SingleResponse<UserRegistrationToken>>, _>(|t| {
58            // Get the revoked token sample
59            let [_, revoked_token] = UserRegistrationToken::samples();
60            let id = revoked_token.id();
61            let response = SingleResponse::new(revoked_token, format!("/api/admin/v1/user-registration-tokens/{id}/revoke"));
62            t.description("Registration token was revoked").example(response)
63        })
64        .response_with::<400, RouteError, _>(|t| {
65            let response = ErrorResponse::from_error(&RouteError::AlreadyRevoked(Ulid::nil()));
66            t.description("Token is already revoked").example(response)
67        })
68        .response_with::<404, RouteError, _>(|t| {
69            let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
70            t.description("Registration token was not found").example(response)
71        })
72}
73
74#[tracing::instrument(name = "handler.admin.v1.user_registration_tokens.revoke", skip_all)]
75pub async fn handler(
76    CallContext {
77        mut repo, clock, ..
78    }: CallContext,
79    id: UlidPathParam,
80) -> Result<Json<SingleResponse<UserRegistrationToken>>, RouteError> {
81    let id = *id;
82    let token = repo
83        .user_registration_token()
84        .lookup(id)
85        .await?
86        .ok_or(RouteError::NotFound(id))?;
87
88    // Check if the token is already revoked
89    if token.revoked_at.is_some() {
90        return Err(RouteError::AlreadyRevoked(id));
91    }
92
93    // Revoke the token
94    let token = repo.user_registration_token().revoke(&clock, token).await?;
95
96    repo.save().await?;
97
98    Ok(Json(SingleResponse::new(
99        UserRegistrationToken::new(token, clock.now()),
100        format!("/api/admin/v1/user-registration-tokens/{id}/revoke"),
101    )))
102}
103
104#[cfg(test)]
105mod tests {
106    use chrono::Duration;
107    use hyper::{Request, StatusCode};
108    use mas_storage::Clock as _;
109    use sqlx::PgPool;
110
111    use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
112
113    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
114    async fn test_revoke_token(pool: PgPool) {
115        setup();
116        let mut state = TestState::from_pool(pool).await.unwrap();
117        let token = state.token_with_scope("urn:mas:admin").await;
118
119        let mut repo = state.repository().await.unwrap();
120        let registration_token = repo
121            .user_registration_token()
122            .add(
123                &mut state.rng(),
124                &state.clock,
125                "test_token_456".to_owned(),
126                Some(5),
127                None,
128            )
129            .await
130            .unwrap();
131        repo.save().await.unwrap();
132
133        let request = Request::post(format!(
134            "/api/admin/v1/user-registration-tokens/{}/revoke",
135            registration_token.id
136        ))
137        .bearer(&token)
138        .empty();
139        let response = state.request(request).await;
140        response.assert_status(StatusCode::OK);
141        let body: serde_json::Value = response.json();
142
143        // The revoked_at timestamp should be the same as the current time
144        assert_eq!(
145            body["data"]["attributes"]["revoked_at"],
146            serde_json::json!(state.clock.now())
147        );
148    }
149
150    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
151    async fn test_revoke_already_revoked_token(pool: PgPool) {
152        setup();
153        let mut state = TestState::from_pool(pool).await.unwrap();
154        let token = state.token_with_scope("urn:mas:admin").await;
155
156        let mut repo = state.repository().await.unwrap();
157        let registration_token = repo
158            .user_registration_token()
159            .add(
160                &mut state.rng(),
161                &state.clock,
162                "test_token_789".to_owned(),
163                None,
164                None,
165            )
166            .await
167            .unwrap();
168
169        // Revoke the token first
170        let registration_token = repo
171            .user_registration_token()
172            .revoke(&state.clock, registration_token)
173            .await
174            .unwrap();
175
176        repo.save().await.unwrap();
177
178        // Move the clock forward
179        state.clock.advance(Duration::try_minutes(1).unwrap());
180
181        let request = Request::post(format!(
182            "/api/admin/v1/user-registration-tokens/{}/revoke",
183            registration_token.id
184        ))
185        .bearer(&token)
186        .empty();
187        let response = state.request(request).await;
188        response.assert_status(StatusCode::BAD_REQUEST);
189        let body: serde_json::Value = response.json();
190        assert_eq!(
191            body["errors"][0]["title"],
192            format!(
193                "Registration token with ID {} is already revoked",
194                registration_token.id
195            )
196        );
197    }
198
199    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
200    async fn test_revoke_unknown_token(pool: PgPool) {
201        setup();
202        let mut state = TestState::from_pool(pool).await.unwrap();
203        let token = state.token_with_scope("urn:mas:admin").await;
204
205        let request = Request::post(
206            "/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081/revoke",
207        )
208        .bearer(&token)
209        .empty();
210        let response = state.request(request).await;
211        response.assert_status(StatusCode::NOT_FOUND);
212        let body: serde_json::Value = response.json();
213        assert_eq!(
214            body["errors"][0]["title"],
215            "Registration token with ID 01040G2081040G2081040G2081 not found"
216        );
217    }
218}