Skip to content

Commit 8bf890a

Browse files
committed
Admin API to finish an OAuth2 session
1 parent defb2cf commit 8bf890a

File tree

4 files changed

+336
-0
lines changed

4 files changed

+336
-0
lines changed

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ where
6060
"/oauth2-sessions/{id}",
6161
get_with(self::oauth2_sessions::get, self::oauth2_sessions::get_doc),
6262
)
63+
.api_route(
64+
"/oauth2-sessions/{id}/finish",
65+
post_with(
66+
self::oauth2_sessions::finish,
67+
self::oauth2_sessions::finish_doc,
68+
),
69+
)
6370
.api_route(
6471
"/policy-data",
6572
post_with(self::policy_data::set, self::policy_data::set_doc),
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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+
7+
use aide::{NoApi, OperationIo, transform::TransformOperation};
8+
use axum::{Json, response::IntoResponse};
9+
use hyper::StatusCode;
10+
use mas_axum_utils::record_error;
11+
use mas_data_model::BoxRng;
12+
use mas_storage::queue::{QueueJobRepositoryExt as _, SyncDevicesJob};
13+
use ulid::Ulid;
14+
15+
use crate::{
16+
admin::{
17+
call_context::CallContext,
18+
model::{OAuth2Session, Resource},
19+
params::UlidPathParam,
20+
response::{ErrorResponse, SingleResponse},
21+
},
22+
impl_from_error_for_route,
23+
};
24+
25+
#[derive(Debug, thiserror::Error, OperationIo)]
26+
#[aide(output_with = "Json<ErrorResponse>")]
27+
pub enum RouteError {
28+
#[error(transparent)]
29+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
30+
31+
#[error("OAuth 2.0 session with ID {0} not found")]
32+
NotFound(Ulid),
33+
34+
#[error("OAuth 2.0 session with ID {0} is already finished")]
35+
AlreadyFinished(Ulid),
36+
}
37+
38+
impl_from_error_for_route!(mas_storage::RepositoryError);
39+
40+
impl IntoResponse for RouteError {
41+
fn into_response(self) -> axum::response::Response {
42+
let error = ErrorResponse::from_error(&self);
43+
let sentry_event_id = record_error!(self, Self::Internal(_));
44+
let status = match self {
45+
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
46+
Self::NotFound(_) => StatusCode::NOT_FOUND,
47+
Self::AlreadyFinished(_) => StatusCode::BAD_REQUEST,
48+
};
49+
(status, sentry_event_id, Json(error)).into_response()
50+
}
51+
}
52+
53+
pub fn doc(operation: TransformOperation) -> TransformOperation {
54+
operation
55+
.id("finishOAuth2Session")
56+
.summary("Finish an OAuth 2.0 session")
57+
.description(
58+
"Calling this endpoint will finish the OAuth 2.0 session, preventing any further use. If the session has a user associated with it, a job will be scheduled to sync the user's devices with the homeserver.",
59+
)
60+
.tag("oauth2-session")
61+
.response_with::<200, Json<SingleResponse<OAuth2Session>>, _>(|t| {
62+
// Get the finished session sample
63+
let [_, _, finished_session] = OAuth2Session::samples();
64+
let id = finished_session.id();
65+
let response = SingleResponse::new(
66+
finished_session,
67+
format!("/api/admin/v1/oauth2-sessions/{id}/finish"),
68+
);
69+
t.description("OAuth 2.0 session was finished").example(response)
70+
})
71+
.response_with::<400, RouteError, _>(|t| {
72+
let response = ErrorResponse::from_error(&RouteError::AlreadyFinished(Ulid::nil()));
73+
t.description("Session is already finished")
74+
.example(response)
75+
})
76+
.response_with::<404, RouteError, _>(|t| {
77+
let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
78+
t.description("OAuth 2.0 session was not found")
79+
.example(response)
80+
})
81+
}
82+
83+
#[tracing::instrument(name = "handler.admin.v1.oauth2_sessions.finish", skip_all)]
84+
pub async fn handler(
85+
CallContext {
86+
mut repo, clock, ..
87+
}: CallContext,
88+
NoApi(mut rng): NoApi<BoxRng>,
89+
id: UlidPathParam,
90+
) -> Result<Json<SingleResponse<OAuth2Session>>, RouteError> {
91+
let id = *id;
92+
let session = repo
93+
.oauth2_session()
94+
.lookup(id)
95+
.await?
96+
.ok_or(RouteError::NotFound(id))?;
97+
98+
// Check if the session is already finished
99+
if session.finished_at().is_some() {
100+
return Err(RouteError::AlreadyFinished(id));
101+
}
102+
103+
// If the session has a user associated with it, schedule a job to sync devices
104+
if let Some(user_id) = session.user_id {
105+
tracing::info!(user.id = %user_id, "Scheduling device sync job for user");
106+
let job = SyncDevicesJob::new_for_id(user_id);
107+
repo.queue_job().schedule_job(&mut rng, &clock, job).await?;
108+
}
109+
110+
// Finish the session
111+
let session = repo.oauth2_session().finish(&clock, session).await?;
112+
113+
repo.save().await?;
114+
115+
Ok(Json(SingleResponse::new(
116+
OAuth2Session::from(session),
117+
format!("/api/admin/v1/oauth2-sessions/{id}/finish"),
118+
)))
119+
}
120+
121+
#[cfg(test)]
122+
mod tests {
123+
use chrono::Duration;
124+
use hyper::{Request, StatusCode};
125+
use mas_data_model::{AccessToken, Clock as _};
126+
use sqlx::PgPool;
127+
128+
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
129+
130+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
131+
async fn test_finish_session(pool: PgPool) {
132+
setup();
133+
let mut state = TestState::from_pool(pool).await.unwrap();
134+
let token = state.token_with_scope("urn:mas:admin").await;
135+
136+
// Get the session ID from the token we just created
137+
let mut repo = state.repository().await.unwrap();
138+
let AccessToken { session_id, .. } = repo
139+
.oauth2_access_token()
140+
.find_by_token(&token)
141+
.await
142+
.unwrap()
143+
.unwrap();
144+
repo.save().await.unwrap();
145+
146+
let request = Request::post(format!("/api/admin/v1/oauth2-sessions/{session_id}/finish"))
147+
.bearer(&token)
148+
.empty();
149+
let response = state.request(request).await;
150+
response.assert_status(StatusCode::OK);
151+
let body: serde_json::Value = response.json();
152+
153+
// The finished_at timestamp should be the same as the current time
154+
assert_eq!(
155+
body["data"]["attributes"]["finished_at"],
156+
serde_json::json!(state.clock.now())
157+
);
158+
}
159+
160+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
161+
async fn test_finish_already_finished_session(pool: PgPool) {
162+
setup();
163+
let mut state = TestState::from_pool(pool).await.unwrap();
164+
165+
// Create first admin token for the API call
166+
let admin_token = state.token_with_scope("urn:mas:admin").await;
167+
168+
// Create a second admin session that we'll finish
169+
let second_admin_token = state.token_with_scope("urn:mas:admin").await;
170+
171+
// Get the second session and finish it first
172+
let mut repo = state.repository().await.unwrap();
173+
let AccessToken { session_id, .. } = repo
174+
.oauth2_access_token()
175+
.find_by_token(&second_admin_token)
176+
.await
177+
.unwrap()
178+
.unwrap();
179+
180+
let session = repo
181+
.oauth2_session()
182+
.lookup(session_id)
183+
.await
184+
.unwrap()
185+
.unwrap();
186+
187+
// Finish the session first
188+
let session = repo
189+
.oauth2_session()
190+
.finish(&state.clock, session)
191+
.await
192+
.unwrap();
193+
194+
repo.save().await.unwrap();
195+
196+
// Move the clock forward
197+
state.clock.advance(Duration::try_minutes(1).unwrap());
198+
199+
let request = Request::post(format!(
200+
"/api/admin/v1/oauth2-sessions/{}/finish",
201+
session.id
202+
))
203+
.bearer(&admin_token)
204+
.empty();
205+
let response = state.request(request).await;
206+
response.assert_status(StatusCode::BAD_REQUEST);
207+
let body: serde_json::Value = response.json();
208+
assert_eq!(
209+
body["errors"][0]["title"],
210+
format!(
211+
"OAuth 2.0 session with ID {} is already finished",
212+
session.id
213+
)
214+
);
215+
}
216+
217+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
218+
async fn test_finish_unknown_session(pool: PgPool) {
219+
setup();
220+
let mut state = TestState::from_pool(pool).await.unwrap();
221+
let token = state.token_with_scope("urn:mas:admin").await;
222+
223+
let request =
224+
Request::post("/api/admin/v1/oauth2-sessions/01040G2081040G2081040G2081/finish")
225+
.bearer(&token)
226+
.empty();
227+
let response = state.request(request).await;
228+
response.assert_status(StatusCode::NOT_FOUND);
229+
let body: serde_json::Value = response.json();
230+
assert_eq!(
231+
body["errors"][0]["title"],
232+
"OAuth 2.0 session with ID 01040G2081040G2081040G2081 not found"
233+
);
234+
}
235+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
55
// Please see LICENSE files in the repository root for full details.
66

7+
mod finish;
78
mod get;
89
mod list;
910

1011
pub use self::{
12+
finish::{doc as finish_doc, handler as finish},
1113
get::{doc as get_doc, handler as get},
1214
list::{doc as list_doc, handler as list},
1315
};

docs/api/spec.json

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,98 @@
687687
}
688688
}
689689
},
690+
"/api/admin/v1/oauth2-sessions/{id}/finish": {
691+
"post": {
692+
"tags": [
693+
"oauth2-session"
694+
],
695+
"summary": "Finish an OAuth 2.0 session",
696+
"description": "Calling this endpoint will finish the OAuth 2.0 session, preventing any further use. If the session has a user associated with it, a job will be scheduled to sync the user's devices with the homeserver.",
697+
"operationId": "finishOAuth2Session",
698+
"parameters": [
699+
{
700+
"in": "path",
701+
"name": "id",
702+
"required": true,
703+
"schema": {
704+
"title": "The ID of the resource",
705+
"$ref": "#/components/schemas/ULID"
706+
},
707+
"style": "simple"
708+
}
709+
],
710+
"responses": {
711+
"200": {
712+
"description": "OAuth 2.0 session was finished",
713+
"content": {
714+
"application/json": {
715+
"schema": {
716+
"$ref": "#/components/schemas/SingleResponse_for_OAuth2Session"
717+
},
718+
"example": {
719+
"data": {
720+
"type": "oauth2-session",
721+
"id": "030C1G60R30C1G60R30C1G60R3",
722+
"attributes": {
723+
"created_at": "1970-01-01T00:00:00Z",
724+
"finished_at": "1970-01-01T00:00:00Z",
725+
"user_id": "040G2081040G2081040G208104",
726+
"user_session_id": "050M2GA1850M2GA1850M2GA185",
727+
"client_id": "060R30C1G60R30C1G60R30C1G6",
728+
"scope": "urn:matrix:client:api:*",
729+
"user_agent": "Mozilla/5.0",
730+
"last_active_at": "1970-01-01T00:00:00Z",
731+
"last_active_ip": "127.0.0.1",
732+
"human_name": null
733+
},
734+
"links": {
735+
"self": "/api/admin/v1/oauth2-sessions/030C1G60R30C1G60R30C1G60R3"
736+
}
737+
},
738+
"links": {
739+
"self": "/api/admin/v1/oauth2-sessions/030C1G60R30C1G60R30C1G60R3/finish"
740+
}
741+
}
742+
}
743+
}
744+
},
745+
"400": {
746+
"description": "Session is already finished",
747+
"content": {
748+
"application/json": {
749+
"schema": {
750+
"$ref": "#/components/schemas/ErrorResponse"
751+
},
752+
"example": {
753+
"errors": [
754+
{
755+
"title": "OAuth 2.0 session with ID 00000000000000000000000000 is already finished"
756+
}
757+
]
758+
}
759+
}
760+
}
761+
},
762+
"404": {
763+
"description": "OAuth 2.0 session was not found",
764+
"content": {
765+
"application/json": {
766+
"schema": {
767+
"$ref": "#/components/schemas/ErrorResponse"
768+
},
769+
"example": {
770+
"errors": [
771+
{
772+
"title": "OAuth 2.0 session with ID 00000000000000000000000000 not found"
773+
}
774+
]
775+
}
776+
}
777+
}
778+
}
779+
}
780+
}
781+
},
690782
"/api/admin/v1/policy-data": {
691783
"post": {
692784
"tags": [

0 commit comments

Comments
 (0)