Skip to content
This repository was archived by the owner on Sep 10, 2024. It is now read-only.

Commit 96ca4ae

Browse files
committed
admin: list OAuth 2.0 sessions API
1 parent 73d431b commit 96ca4ae

File tree

4 files changed

+703
-26
lines changed

4 files changed

+703
-26
lines changed

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ where
3030
CallContext: FromRequestParts<S>,
3131
{
3232
ApiRouter::<S>::new()
33+
.api_route(
34+
"/oauth2-sessions",
35+
get_with(self::oauth2_sessions::list, self::oauth2_sessions::list_doc),
36+
)
3337
.api_route(
3438
"/users",
3539
get_with(self::users::list, self::users::list_doc)
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
// Copyright 2024 The Matrix.org Foundation C.I.C.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
use std::str::FromStr;
16+
17+
use aide::{transform::TransformOperation, OperationIo};
18+
use axum::{
19+
extract::{rejection::QueryRejection, Query},
20+
response::IntoResponse,
21+
Json,
22+
};
23+
use axum_macros::FromRequestParts;
24+
use hyper::StatusCode;
25+
use mas_storage::{oauth2::OAuth2SessionFilter, Page};
26+
use oauth2_types::scope::{Scope, ScopeToken};
27+
use schemars::JsonSchema;
28+
use serde::Deserialize;
29+
use ulid::Ulid;
30+
31+
use crate::{
32+
admin::{
33+
call_context::CallContext,
34+
model::{OAuth2Session, Resource},
35+
params::Pagination,
36+
response::{ErrorResponse, PaginatedResponse},
37+
},
38+
impl_from_error_for_route,
39+
};
40+
41+
#[derive(Deserialize, JsonSchema, Clone, Copy)]
42+
#[serde(rename_all = "snake_case")]
43+
enum OAuth2SessionStatus {
44+
Active,
45+
Finished,
46+
}
47+
48+
impl std::fmt::Display for OAuth2SessionStatus {
49+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50+
match self {
51+
Self::Active => write!(f, "active"),
52+
Self::Finished => write!(f, "finished"),
53+
}
54+
}
55+
}
56+
57+
#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)]
58+
#[serde(rename = "OAuth2SessionFilter")]
59+
#[aide(input_with = "Query<FilterParams>")]
60+
#[from_request(via(Query), rejection(RouteError))]
61+
pub struct FilterParams {
62+
/// Retrieve the items for the given user
63+
#[serde(rename = "filter[user]")]
64+
#[schemars(with = "Option<crate::admin::schema::Ulid>")]
65+
user: Option<Ulid>,
66+
67+
/// Retrieve the items for the given client
68+
#[serde(rename = "filter[client]")]
69+
#[schemars(with = "Option<crate::admin::schema::Ulid>")]
70+
client: Option<Ulid>,
71+
72+
/// Retrieve the items started from the given browser session
73+
#[serde(rename = "filter[user-session]")]
74+
#[schemars(with = "Option<crate::admin::schema::Ulid>")]
75+
user_session: Option<Ulid>,
76+
77+
/// Retrieve the items with the given scope
78+
#[serde(default, rename = "filter[scope]")]
79+
scope: Vec<String>,
80+
81+
/// Retrieve the items with the given status
82+
///
83+
/// Defaults to retrieve all sessions, including finished ones.
84+
///
85+
/// * `active`: Only retrieve active sessions
86+
///
87+
/// * `finished`: Only retrieve finished sessions
88+
#[serde(rename = "filter[status]")]
89+
status: Option<OAuth2SessionStatus>,
90+
}
91+
92+
impl std::fmt::Display for FilterParams {
93+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94+
let mut sep = '?';
95+
96+
if let Some(user) = self.user {
97+
write!(f, "{sep}filter[user]={user}")?;
98+
sep = '&';
99+
}
100+
101+
if let Some(client) = self.client {
102+
write!(f, "{sep}filter[client]={client}")?;
103+
sep = '&';
104+
}
105+
106+
if let Some(user_session) = self.user_session {
107+
write!(f, "{sep}filter[user-session]={user_session}")?;
108+
sep = '&';
109+
}
110+
111+
for scope in &self.scope {
112+
write!(f, "{sep}filter[scope]={scope}")?;
113+
sep = '&';
114+
}
115+
116+
if let Some(status) = self.status {
117+
write!(f, "{sep}filter[status]={status}")?;
118+
sep = '&';
119+
}
120+
121+
let _ = sep;
122+
Ok(())
123+
}
124+
}
125+
126+
#[derive(Debug, thiserror::Error, OperationIo)]
127+
#[aide(output_with = "Json<ErrorResponse>")]
128+
pub enum RouteError {
129+
#[error(transparent)]
130+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
131+
132+
#[error("User ID {0} not found")]
133+
UserNotFound(Ulid),
134+
135+
#[error("Client ID {0} not found")]
136+
ClientNotFound(Ulid),
137+
138+
#[error("User session ID {0} not found")]
139+
UserSessionNotFound(Ulid),
140+
141+
#[error("Invalid filter parameters")]
142+
InvalidFilter(#[from] QueryRejection),
143+
144+
#[error("Invalid scope {0:?} in filter parameters")]
145+
InvalidScope(String),
146+
}
147+
148+
impl_from_error_for_route!(mas_storage::RepositoryError);
149+
150+
impl IntoResponse for RouteError {
151+
fn into_response(self) -> axum::response::Response {
152+
let error = ErrorResponse::from_error(&self);
153+
let status = match self {
154+
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
155+
Self::UserNotFound(_) | Self::ClientNotFound(_) | Self::UserSessionNotFound(_) => {
156+
StatusCode::NOT_FOUND
157+
}
158+
Self::InvalidScope(_) | Self::InvalidFilter(_) => StatusCode::BAD_REQUEST,
159+
};
160+
(status, Json(error)).into_response()
161+
}
162+
}
163+
164+
pub fn doc(operation: TransformOperation) -> TransformOperation {
165+
operation
166+
.id("listOAuth2Sessions")
167+
.summary("List OAuth 2.0 sessions")
168+
.description("Retrieve a list of OAuth 2.0 sessions.
169+
Note that by default, all sessions, including finished ones are returned, with the oldest first.
170+
Use the `filter[status]` parameter to filter the sessions by their status and `page[last]` parameter to retrieve the last N sessions.")
171+
.tag("oauth2-session")
172+
.response_with::<200, Json<PaginatedResponse<OAuth2Session>>, _>(|t| {
173+
let sessions = OAuth2Session::samples();
174+
let pagination = mas_storage::Pagination::first(sessions.len());
175+
let page = Page {
176+
edges: sessions.into(),
177+
has_next_page: true,
178+
has_previous_page: false,
179+
};
180+
181+
t.description("Paginated response of OAuth 2.0 sessions")
182+
.example(PaginatedResponse::new(
183+
page,
184+
pagination,
185+
42,
186+
OAuth2Session::PATH,
187+
))
188+
})
189+
.response_with::<404, RouteError, _>(|t| {
190+
let response = ErrorResponse::from_error(&RouteError::UserNotFound(Ulid::nil()));
191+
t.description("User was not found").example(response)
192+
})
193+
.response_with::<400, RouteError, _>(|t| {
194+
let response = ErrorResponse::from_error(&RouteError::InvalidScope("not a valid scope".to_owned()));
195+
t.description("Invalid scope").example(response)
196+
})
197+
}
198+
199+
#[tracing::instrument(name = "handler.admin.v1.oauth2_sessions.list", skip_all, err)]
200+
pub async fn handler(
201+
CallContext { mut repo, .. }: CallContext,
202+
Pagination(pagination): Pagination,
203+
params: FilterParams,
204+
) -> Result<Json<PaginatedResponse<OAuth2Session>>, RouteError> {
205+
let base = format!("{path}{params}", path = OAuth2Session::PATH);
206+
let filter = OAuth2SessionFilter::default();
207+
208+
// Load the user from the filter
209+
let user = if let Some(user_id) = params.user {
210+
let user = repo
211+
.user()
212+
.lookup(user_id)
213+
.await?
214+
.ok_or(RouteError::UserNotFound(user_id))?;
215+
216+
Some(user)
217+
} else {
218+
None
219+
};
220+
221+
let filter = match &user {
222+
Some(user) => filter.for_user(user),
223+
None => filter,
224+
};
225+
226+
let client = if let Some(client_id) = params.client {
227+
let client = repo
228+
.oauth2_client()
229+
.lookup(client_id)
230+
.await?
231+
.ok_or(RouteError::ClientNotFound(client_id))?;
232+
233+
Some(client)
234+
} else {
235+
None
236+
};
237+
238+
let filter = match &client {
239+
Some(client) => filter.for_client(client),
240+
None => filter,
241+
};
242+
243+
let user_session = if let Some(user_session_id) = params.user_session {
244+
let user_session = repo
245+
.browser_session()
246+
.lookup(user_session_id)
247+
.await?
248+
.ok_or(RouteError::UserSessionNotFound(user_session_id))?;
249+
250+
Some(user_session)
251+
} else {
252+
None
253+
};
254+
255+
let filter = match &user_session {
256+
Some(user_session) => filter.for_browser_session(user_session),
257+
None => filter,
258+
};
259+
260+
let scope: Scope = params
261+
.scope
262+
.into_iter()
263+
.map(|s| ScopeToken::from_str(&s).map_err(|_| RouteError::InvalidScope(s)))
264+
.collect::<Result<_, _>>()?;
265+
266+
let filter = if scope.is_empty() {
267+
filter
268+
} else {
269+
filter.with_scope(&scope)
270+
};
271+
272+
let filter = match params.status {
273+
Some(OAuth2SessionStatus::Active) => filter.active_only(),
274+
Some(OAuth2SessionStatus::Finished) => filter.finished_only(),
275+
None => filter,
276+
};
277+
278+
let page = repo.oauth2_session().list(filter, pagination).await?;
279+
let count = repo.oauth2_session().count(filter).await?;
280+
281+
Ok(Json(PaginatedResponse::new(
282+
page.map(OAuth2Session::from),
283+
pagination,
284+
count,
285+
&base,
286+
)))
287+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@
1111
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
14+
15+
mod list;
16+
17+
pub use self::list::{doc as list_doc, handler as list};

0 commit comments

Comments
 (0)