Skip to content

Commit 1aa6e5b

Browse files
committed
Admin API to revoke user registration tokens
1 parent 5969fde commit 1aa6e5b

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
@@ -2391,6 +2391,95 @@
23912391
}
23922392
}
23932393
},
2394+
"/api/admin/v1/user-registration-tokens/{id}/revoke": {
2395+
"post": {
2396+
"tags": [
2397+
"user-registration-token"
2398+
],
2399+
"summary": "Revoke a user registration token",
2400+
"description": "Calling this endpoint will revoke the user registration token, preventing it from being used for new registrations.",
2401+
"operationId": "revokeUserRegistrationToken",
2402+
"parameters": [
2403+
{
2404+
"in": "path",
2405+
"name": "id",
2406+
"required": true,
2407+
"schema": {
2408+
"title": "The ID of the resource",
2409+
"$ref": "#/components/schemas/ULID"
2410+
},
2411+
"style": "simple"
2412+
}
2413+
],
2414+
"responses": {
2415+
"200": {
2416+
"description": "Registration token was revoked",
2417+
"content": {
2418+
"application/json": {
2419+
"schema": {
2420+
"$ref": "#/components/schemas/SingleResponse_for_UserRegistrationToken"
2421+
},
2422+
"example": {
2423+
"data": {
2424+
"type": "user-registration_token",
2425+
"id": "02081040G2081040G2081040G2",
2426+
"attributes": {
2427+
"token": "xyz789abc012",
2428+
"usage_limit": null,
2429+
"times_used": 0,
2430+
"created_at": "1970-01-01T00:00:00Z",
2431+
"last_used_at": null,
2432+
"expires_at": null,
2433+
"revoked_at": "1970-01-01T00:00:00Z"
2434+
},
2435+
"links": {
2436+
"self": "/api/admin/v1/user-registration-tokens/02081040G2081040G2081040G2"
2437+
}
2438+
},
2439+
"links": {
2440+
"self": "/api/admin/v1/user-registration-tokens/02081040G2081040G2081040G2/revoke"
2441+
}
2442+
}
2443+
}
2444+
}
2445+
},
2446+
"400": {
2447+
"description": "Token is already revoked",
2448+
"content": {
2449+
"application/json": {
2450+
"schema": {
2451+
"$ref": "#/components/schemas/ErrorResponse"
2452+
},
2453+
"example": {
2454+
"errors": [
2455+
{
2456+
"title": "Registration token with ID 00000000000000000000000000 is already revoked"
2457+
}
2458+
]
2459+
}
2460+
}
2461+
}
2462+
},
2463+
"404": {
2464+
"description": "Registration token was not found",
2465+
"content": {
2466+
"application/json": {
2467+
"schema": {
2468+
"$ref": "#/components/schemas/ErrorResponse"
2469+
},
2470+
"example": {
2471+
"errors": [
2472+
{
2473+
"title": "Registration token with ID 00000000000000000000000000 not found"
2474+
}
2475+
]
2476+
}
2477+
}
2478+
}
2479+
}
2480+
}
2481+
}
2482+
},
23942483
"/api/admin/v1/upstream-oauth-links": {
23952484
"get": {
23962485
"tags": [

0 commit comments

Comments
 (0)