Skip to content

Commit ed31b2f

Browse files
authored
Admin API to un-revoke and edit registration tokens (#4637)
2 parents 6fe8d22 + 989c97a commit ed31b2f

11 files changed

+1357
-0
lines changed

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ where
136136
get_with(
137137
self::user_registration_tokens::get,
138138
self::user_registration_tokens::get_doc,
139+
)
140+
.put_with(
141+
self::user_registration_tokens::update,
142+
self::user_registration_tokens::update_doc,
139143
),
140144
)
141145
.api_route(
@@ -145,6 +149,13 @@ where
145149
self::user_registration_tokens::revoke_doc,
146150
),
147151
)
152+
.api_route(
153+
"/user-registration-tokens/{id}/unrevoke",
154+
post_with(
155+
self::user_registration_tokens::unrevoke,
156+
self::user_registration_tokens::unrevoke_doc,
157+
),
158+
)
148159
.api_route(
149160
"/upstream-oauth-links",
150161
get_with(

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ use crate::{
2525
#[derive(Debug, thiserror::Error, OperationIo)]
2626
#[aide(output_with = "Json<ErrorResponse>")]
2727
pub enum RouteError {
28+
#[error("A registration token with the same token already exists")]
29+
Conflict(mas_data_model::UserRegistrationToken),
30+
2831
#[error(transparent)]
2932
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
3033
}
@@ -36,6 +39,7 @@ impl IntoResponse for RouteError {
3639
let error = ErrorResponse::from_error(&self);
3740
let sentry_event_id = record_error!(self, Self::Internal(_));
3841
let status = match self {
42+
Self::Conflict(_) => StatusCode::CONFLICT,
3943
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
4044
};
4145
(status, sentry_event_id, Json(error)).into_response()
@@ -83,6 +87,12 @@ pub async fn handler(
8387
.token
8488
.unwrap_or_else(|| Alphanumeric.sample_string(&mut rng, 12));
8589

90+
// See if we have an existing token with the same token
91+
let existing_token = repo.user_registration_token().find_by_token(&token).await?;
92+
if let Some(existing_token) = existing_token {
93+
return Err(RouteError::Conflict(existing_token));
94+
}
95+
8696
let registration_token = repo
8797
.user_registration_token()
8898
.add(
@@ -196,4 +206,56 @@ mod tests {
196206
}
197207
"#);
198208
}
209+
210+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
211+
async fn test_create_conflict(pool: PgPool) {
212+
setup();
213+
let mut state = TestState::from_pool(pool).await.unwrap();
214+
let token = state.token_with_scope("urn:mas:admin").await;
215+
216+
let request = Request::post("/api/admin/v1/user-registration-tokens")
217+
.bearer(&token)
218+
.json(serde_json::json!({
219+
"token": "test_token_123",
220+
"usage_limit": 5
221+
}));
222+
let response = state.request(request).await;
223+
response.assert_status(StatusCode::CREATED);
224+
225+
let body: serde_json::Value = response.json();
226+
227+
assert_json_snapshot!(body, @r#"
228+
{
229+
"data": {
230+
"type": "user-registration_token",
231+
"id": "01FSHN9AG0MZAA6S4AF7CTV32E",
232+
"attributes": {
233+
"token": "test_token_123",
234+
"valid": true,
235+
"usage_limit": 5,
236+
"times_used": 0,
237+
"created_at": "2022-01-16T14:40:00Z",
238+
"last_used_at": null,
239+
"expires_at": null,
240+
"revoked_at": null
241+
},
242+
"links": {
243+
"self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E"
244+
}
245+
},
246+
"links": {
247+
"self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E"
248+
}
249+
}
250+
"#);
251+
252+
let request = Request::post("/api/admin/v1/user-registration-tokens")
253+
.bearer(&token)
254+
.json(serde_json::json!({
255+
"token": "test_token_123",
256+
"usage_limit": 5
257+
}));
258+
let response = state.request(request).await;
259+
response.assert_status(StatusCode::CONFLICT);
260+
}
199261
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@ mod add;
77
mod get;
88
mod list;
99
mod revoke;
10+
mod unrevoke;
11+
mod update;
1012

1113
pub use self::{
1214
add::{doc as add_doc, handler as add},
1315
get::{doc as get_doc, handler as get},
1416
list::{doc as list_doc, handler as list},
1517
revoke::{doc as revoke_doc, handler as revoke},
18+
unrevoke::{doc as unrevoke_doc, handler as unrevoke},
19+
update::{doc as update_doc, handler as update},
1620
};
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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 not revoked")]
32+
NotRevoked(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::NotRevoked(_) => 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("unrevokeUserRegistrationToken")
53+
.summary("Unrevoke a user registration token")
54+
.description("Calling this endpoint will unrevoke a previously revoked user registration token, allowing it to be used for registrations again (subject to its usage limits and expiration).")
55+
.tag("user-registration-token")
56+
.response_with::<200, Json<SingleResponse<UserRegistrationToken>>, _>(|t| {
57+
// Get the valid token sample
58+
let [valid_token, _] = UserRegistrationToken::samples();
59+
let id = valid_token.id();
60+
let response = SingleResponse::new(valid_token, format!("/api/admin/v1/user-registration-tokens/{id}/unrevoke"));
61+
t.description("Registration token was unrevoked").example(response)
62+
})
63+
.response_with::<400, RouteError, _>(|t| {
64+
let response = ErrorResponse::from_error(&RouteError::NotRevoked(Ulid::nil()));
65+
t.description("Token is not 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.unrevoke", 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 not revoked
88+
if token.revoked_at.is_none() {
89+
return Err(RouteError::NotRevoked(id));
90+
}
91+
92+
// Unrevoke the token using the repository method
93+
let token = repo.user_registration_token().unrevoke(token).await?;
94+
95+
repo.save().await?;
96+
97+
Ok(Json(SingleResponse::new(
98+
UserRegistrationToken::new(token, clock.now()),
99+
format!("/api/admin/v1/user-registration-tokens/{id}/unrevoke"),
100+
)))
101+
}
102+
103+
#[cfg(test)]
104+
mod tests {
105+
use hyper::{Request, StatusCode};
106+
use sqlx::PgPool;
107+
108+
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
109+
110+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
111+
async fn test_unrevoke_token(pool: PgPool) {
112+
setup();
113+
let mut state = TestState::from_pool(pool).await.unwrap();
114+
let token = state.token_with_scope("urn:mas:admin").await;
115+
116+
let mut repo = state.repository().await.unwrap();
117+
118+
// Create a token
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+
131+
// Revoke it
132+
let registration_token = repo
133+
.user_registration_token()
134+
.revoke(&state.clock, registration_token)
135+
.await
136+
.unwrap();
137+
138+
repo.save().await.unwrap();
139+
140+
// Now unrevoke it
141+
let request = Request::post(format!(
142+
"/api/admin/v1/user-registration-tokens/{}/unrevoke",
143+
registration_token.id
144+
))
145+
.bearer(&token)
146+
.empty();
147+
let response = state.request(request).await;
148+
response.assert_status(StatusCode::OK);
149+
let body: serde_json::Value = response.json();
150+
151+
// The revoked_at timestamp should be null
152+
insta::assert_json_snapshot!(body, @r#"
153+
{
154+
"data": {
155+
"type": "user-registration_token",
156+
"id": "01FSHN9AG0MZAA6S4AF7CTV32E",
157+
"attributes": {
158+
"token": "test_token_456",
159+
"valid": true,
160+
"usage_limit": 5,
161+
"times_used": 0,
162+
"created_at": "2022-01-16T14:40:00Z",
163+
"last_used_at": null,
164+
"expires_at": null,
165+
"revoked_at": null
166+
},
167+
"links": {
168+
"self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E"
169+
}
170+
},
171+
"links": {
172+
"self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E/unrevoke"
173+
}
174+
}
175+
"#);
176+
}
177+
178+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
179+
async fn test_unrevoke_not_revoked_token(pool: PgPool) {
180+
setup();
181+
let mut state = TestState::from_pool(pool).await.unwrap();
182+
let token = state.token_with_scope("urn:mas:admin").await;
183+
184+
let mut repo = state.repository().await.unwrap();
185+
let registration_token = repo
186+
.user_registration_token()
187+
.add(
188+
&mut state.rng(),
189+
&state.clock,
190+
"test_token_789".to_owned(),
191+
None,
192+
None,
193+
)
194+
.await
195+
.unwrap();
196+
197+
repo.save().await.unwrap();
198+
199+
// Try to unrevoke a token that's not revoked
200+
let request = Request::post(format!(
201+
"/api/admin/v1/user-registration-tokens/{}/unrevoke",
202+
registration_token.id
203+
))
204+
.bearer(&token)
205+
.empty();
206+
let response = state.request(request).await;
207+
response.assert_status(StatusCode::BAD_REQUEST);
208+
let body: serde_json::Value = response.json();
209+
assert_eq!(
210+
body["errors"][0]["title"],
211+
format!(
212+
"Registration token with ID {} is not revoked",
213+
registration_token.id
214+
)
215+
);
216+
}
217+
218+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
219+
async fn test_unrevoke_unknown_token(pool: PgPool) {
220+
setup();
221+
let mut state = TestState::from_pool(pool).await.unwrap();
222+
let token = state.token_with_scope("urn:mas:admin").await;
223+
224+
let request = Request::post(
225+
"/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081/unrevoke",
226+
)
227+
.bearer(&token)
228+
.empty();
229+
let response = state.request(request).await;
230+
response.assert_status(StatusCode::NOT_FOUND);
231+
let body: serde_json::Value = response.json();
232+
assert_eq!(
233+
body["errors"][0]["title"],
234+
"Registration token with ID 01040G2081040G2081040G2081 not found"
235+
);
236+
}
237+
}

0 commit comments

Comments
 (0)