Skip to content

Commit c512b72

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

File tree

4 files changed

+335
-0
lines changed

4 files changed

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

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)