Skip to content

Commit 723302f

Browse files
committed
Admin API to revoke user registration tokens
1 parent aa7c6b3 commit 723302f

File tree

4 files changed

+315
-0
lines changed

4 files changed

+315
-0
lines changed

crates/handlers/src/admin/v1/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,13 @@ where
138138
self::user_registration_tokens::get_doc,
139139
),
140140
)
141+
.api_route(
142+
"/user-registration-tokens/{id}/revoke",
143+
post_with(
144+
self::user_registration_tokens::revoke,
145+
self::user_registration_tokens::revoke_doc,
146+
),
147+
)
141148
.api_route(
142149
"/upstream-oauth-links",
143150
get_with(

crates/handlers/src/admin/v1/user_registration_tokens/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
mod add;
77
mod get;
88
mod list;
9+
mod revoke;
910

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

docs/api/spec.json

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2413,6 +2413,95 @@
24132413
}
24142414
}
24152415
},
2416+
"/api/admin/v1/user-registration-tokens/{id}/revoke": {
2417+
"post": {
2418+
"tags": [
2419+
"user-registration-token"
2420+
],
2421+
"summary": "Revoke a user registration token",
2422+
"description": "Calling this endpoint will revoke the user registration token, preventing it from being used for new registrations.",
2423+
"operationId": "revokeUserRegistrationToken",
2424+
"parameters": [
2425+
{
2426+
"in": "path",
2427+
"name": "id",
2428+
"required": true,
2429+
"schema": {
2430+
"title": "The ID of the resource",
2431+
"$ref": "#/components/schemas/ULID"
2432+
},
2433+
"style": "simple"
2434+
}
2435+
],
2436+
"responses": {
2437+
"200": {
2438+
"description": "Registration token was revoked",
2439+
"content": {
2440+
"application/json": {
2441+
"schema": {
2442+
"$ref": "#/components/schemas/SingleResponse_for_UserRegistrationToken"
2443+
},
2444+
"example": {
2445+
"data": {
2446+
"type": "user-registration_token",
2447+
"id": "02081040G2081040G2081040G2",
2448+
"attributes": {
2449+
"token": "xyz789abc012",
2450+
"usage_limit": null,
2451+
"times_used": 0,
2452+
"created_at": "1970-01-01T00:00:00Z",
2453+
"last_used_at": null,
2454+
"expires_at": null,
2455+
"revoked_at": "1970-01-01T00:00:00Z"
2456+
},
2457+
"links": {
2458+
"self": "/api/admin/v1/user-registration-tokens/02081040G2081040G2081040G2"
2459+
}
2460+
},
2461+
"links": {
2462+
"self": "/api/admin/v1/user-registration-tokens/02081040G2081040G2081040G2/revoke"
2463+
}
2464+
}
2465+
}
2466+
}
2467+
},
2468+
"400": {
2469+
"description": "Token is already revoked",
2470+
"content": {
2471+
"application/json": {
2472+
"schema": {
2473+
"$ref": "#/components/schemas/ErrorResponse"
2474+
},
2475+
"example": {
2476+
"errors": [
2477+
{
2478+
"title": "Registration token with ID 00000000000000000000000000 is already revoked"
2479+
}
2480+
]
2481+
}
2482+
}
2483+
}
2484+
},
2485+
"404": {
2486+
"description": "Registration token was not found",
2487+
"content": {
2488+
"application/json": {
2489+
"schema": {
2490+
"$ref": "#/components/schemas/ErrorResponse"
2491+
},
2492+
"example": {
2493+
"errors": [
2494+
{
2495+
"title": "Registration token with ID 00000000000000000000000000 not found"
2496+
}
2497+
]
2498+
}
2499+
}
2500+
}
2501+
}
2502+
}
2503+
}
2504+
},
24162505
"/api/admin/v1/upstream-oauth-links": {
24172506
"get": {
24182507
"tags": [

0 commit comments

Comments
 (0)