diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index ce63cdc4..6c44e492 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -35,6 +35,10 @@ where CallContext: FromRequestParts, { ApiRouter::::new() + .api_route( + "/oauth2-sessions", + get_with(self::oauth2_sessions::list, self::oauth2_sessions::list_doc), + ) .api_route( "/users", get_with(self::users::list, self::users::list_doc) diff --git a/crates/handlers/src/admin/v1/oauth2_sessions/list.rs b/crates/handlers/src/admin/v1/oauth2_sessions/list.rs new file mode 100644 index 00000000..94700764 --- /dev/null +++ b/crates/handlers/src/admin/v1/oauth2_sessions/list.rs @@ -0,0 +1,287 @@ +// Copyright 2024 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::str::FromStr; + +use aide::{transform::TransformOperation, OperationIo}; +use axum::{ + extract::{rejection::QueryRejection, Query}, + response::IntoResponse, + Json, +}; +use axum_macros::FromRequestParts; +use hyper::StatusCode; +use mas_storage::{oauth2::OAuth2SessionFilter, Page}; +use oauth2_types::scope::{Scope, ScopeToken}; +use schemars::JsonSchema; +use serde::Deserialize; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{OAuth2Session, Resource}, + params::Pagination, + response::{ErrorResponse, PaginatedResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Deserialize, JsonSchema, Clone, Copy)] +#[serde(rename_all = "snake_case")] +enum OAuth2SessionStatus { + Active, + Finished, +} + +impl std::fmt::Display for OAuth2SessionStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Active => write!(f, "active"), + Self::Finished => write!(f, "finished"), + } + } +} + +#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)] +#[serde(rename = "OAuth2SessionFilter")] +#[aide(input_with = "Query")] +#[from_request(via(Query), rejection(RouteError))] +pub struct FilterParams { + /// Retrieve the items for the given user + #[serde(rename = "filter[user]")] + #[schemars(with = "Option")] + user: Option, + + /// Retrieve the items for the given client + #[serde(rename = "filter[client]")] + #[schemars(with = "Option")] + client: Option, + + /// Retrieve the items started from the given browser session + #[serde(rename = "filter[user-session]")] + #[schemars(with = "Option")] + user_session: Option, + + /// Retrieve the items with the given scope + #[serde(default, rename = "filter[scope]")] + scope: Vec, + + /// Retrieve the items with the given status + /// + /// Defaults to retrieve all sessions, including finished ones. + /// + /// * `active`: Only retrieve active sessions + /// + /// * `finished`: Only retrieve finished sessions + #[serde(rename = "filter[status]")] + status: Option, +} + +impl std::fmt::Display for FilterParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut sep = '?'; + + if let Some(user) = self.user { + write!(f, "{sep}filter[user]={user}")?; + sep = '&'; + } + + if let Some(client) = self.client { + write!(f, "{sep}filter[client]={client}")?; + sep = '&'; + } + + if let Some(user_session) = self.user_session { + write!(f, "{sep}filter[user-session]={user_session}")?; + sep = '&'; + } + + for scope in &self.scope { + write!(f, "{sep}filter[scope]={scope}")?; + sep = '&'; + } + + if let Some(status) = self.status { + write!(f, "{sep}filter[status]={status}")?; + sep = '&'; + } + + let _ = sep; + Ok(()) + } +} + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("User ID {0} not found")] + UserNotFound(Ulid), + + #[error("Client ID {0} not found")] + ClientNotFound(Ulid), + + #[error("User session ID {0} not found")] + UserSessionNotFound(Ulid), + + #[error("Invalid filter parameters")] + InvalidFilter(#[from] QueryRejection), + + #[error("Invalid scope {0:?} in filter parameters")] + InvalidScope(String), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::UserNotFound(_) | Self::ClientNotFound(_) | Self::UserSessionNotFound(_) => { + StatusCode::NOT_FOUND + } + Self::InvalidScope(_) | Self::InvalidFilter(_) => StatusCode::BAD_REQUEST, + }; + (status, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("listOAuth2Sessions") + .summary("List OAuth 2.0 sessions") + .description("Retrieve a list of OAuth 2.0 sessions. +Note that by default, all sessions, including finished ones are returned, with the oldest first. +Use the `filter[status]` parameter to filter the sessions by their status and `page[last]` parameter to retrieve the last N sessions.") + .tag("oauth2-session") + .response_with::<200, Json>, _>(|t| { + let sessions = OAuth2Session::samples(); + let pagination = mas_storage::Pagination::first(sessions.len()); + let page = Page { + edges: sessions.into(), + has_next_page: true, + has_previous_page: false, + }; + + t.description("Paginated response of OAuth 2.0 sessions") + .example(PaginatedResponse::new( + page, + pagination, + 42, + OAuth2Session::PATH, + )) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::UserNotFound(Ulid::nil())); + t.description("User was not found").example(response) + }) + .response_with::<400, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::InvalidScope("not a valid scope".to_owned())); + t.description("Invalid scope").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.oauth2_sessions.list", skip_all, err)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + Pagination(pagination): Pagination, + params: FilterParams, +) -> Result>, RouteError> { + let base = format!("{path}{params}", path = OAuth2Session::PATH); + let filter = OAuth2SessionFilter::default(); + + // Load the user from the filter + let user = if let Some(user_id) = params.user { + let user = repo + .user() + .lookup(user_id) + .await? + .ok_or(RouteError::UserNotFound(user_id))?; + + Some(user) + } else { + None + }; + + let filter = match &user { + Some(user) => filter.for_user(user), + None => filter, + }; + + let client = if let Some(client_id) = params.client { + let client = repo + .oauth2_client() + .lookup(client_id) + .await? + .ok_or(RouteError::ClientNotFound(client_id))?; + + Some(client) + } else { + None + }; + + let filter = match &client { + Some(client) => filter.for_client(client), + None => filter, + }; + + let user_session = if let Some(user_session_id) = params.user_session { + let user_session = repo + .browser_session() + .lookup(user_session_id) + .await? + .ok_or(RouteError::UserSessionNotFound(user_session_id))?; + + Some(user_session) + } else { + None + }; + + let filter = match &user_session { + Some(user_session) => filter.for_browser_session(user_session), + None => filter, + }; + + let scope: Scope = params + .scope + .into_iter() + .map(|s| ScopeToken::from_str(&s).map_err(|_| RouteError::InvalidScope(s))) + .collect::>()?; + + let filter = if scope.is_empty() { + filter + } else { + filter.with_scope(&scope) + }; + + let filter = match params.status { + Some(OAuth2SessionStatus::Active) => filter.active_only(), + Some(OAuth2SessionStatus::Finished) => filter.finished_only(), + None => filter, + }; + + let page = repo.oauth2_session().list(filter, pagination).await?; + let count = repo.oauth2_session().count(filter).await?; + + Ok(Json(PaginatedResponse::new( + page.map(OAuth2Session::from), + pagination, + count, + &base, + ))) +} diff --git a/crates/handlers/src/admin/v1/oauth2_sessions/mod.rs b/crates/handlers/src/admin/v1/oauth2_sessions/mod.rs index 57ca5721..7882063e 100644 --- a/crates/handlers/src/admin/v1/oauth2_sessions/mod.rs +++ b/crates/handlers/src/admin/v1/oauth2_sessions/mod.rs @@ -11,3 +11,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. + +mod list; + +pub use self::list::{doc as list_doc, handler as list}; diff --git a/docs/api/spec.json b/docs/api/spec.json index 66bdbe84..9458ab6c 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -16,6 +16,237 @@ } ], "paths": { + "/api/admin/v1/oauth2-sessions": { + "get": { + "tags": [ + "oauth2-session" + ], + "summary": "List OAuth 2.0 sessions", + "description": "Retrieve a list of OAuth 2.0 sessions.\nNote that by default, all sessions, including finished ones are returned, with the oldest first.\nUse the `filter[status]` parameter to filter the sessions by their status and `page[last]` parameter to retrieve the last N sessions.", + "operationId": "listOAuth2Sessions", + "parameters": [ + { + "in": "query", + "name": "page[before]", + "description": "Retrieve the items before the given ID", + "schema": { + "description": "Retrieve the items before the given ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[after]", + "description": "Retrieve the items after the given ID", + "schema": { + "description": "Retrieve the items after the given ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[first]", + "description": "Retrieve the first N items", + "schema": { + "description": "Retrieve the first N items", + "type": "integer", + "format": "uint", + "minimum": 1.0, + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[last]", + "description": "Retrieve the last N items", + "schema": { + "description": "Retrieve the last N items", + "type": "integer", + "format": "uint", + "minimum": 1.0, + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[user]", + "description": "Retrieve the items for the given user", + "schema": { + "description": "Retrieve the items for the given user", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[client]", + "description": "Retrieve the items for the given client", + "schema": { + "description": "Retrieve the items for the given client", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[user-session]", + "description": "Retrieve the items started from the given browser session", + "schema": { + "description": "Retrieve the items started from the given browser session", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[scope]", + "description": "Retrieve the items with the given scope", + "schema": { + "description": "Retrieve the items with the given scope", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[status]", + "description": "Retrieve the items with the given status\n\nDefaults to retrieve all sessions, including finished ones.\n\n* `active`: Only retrieve active sessions\n\n* `finished`: Only retrieve finished sessions", + "schema": { + "description": "Retrieve the items with the given status\n\nDefaults to retrieve all sessions, including finished ones.\n\n* `active`: Only retrieve active sessions\n\n* `finished`: Only retrieve finished sessions", + "$ref": "#/components/schemas/OAuth2SessionStatus", + "nullable": true + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "Paginated response of OAuth 2.0 sessions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse_for_OAuth2Session" + }, + "example": { + "meta": { + "count": 42 + }, + "data": [ + { + "type": "oauth2-session", + "id": "01040G2081040G2081040G2081", + "attributes": { + "created_at": "1970-01-01T00:00:00Z", + "finished_at": null, + "user_id": "02081040G2081040G2081040G2", + "user_session_id": "030C1G60R30C1G60R30C1G60R3", + "client_id": "040G2081040G2081040G208104", + "scope": "openid", + "user_agent": "Mozilla/5.0", + "last_active_at": "1970-01-01T00:00:00Z", + "last_active_ip": "127.0.0.1" + }, + "links": { + "self": "/api/admin/v1/oauth2-sessions/01040G2081040G2081040G2081" + } + }, + { + "type": "oauth2-session", + "id": "02081040G2081040G2081040G2", + "attributes": { + "created_at": "1970-01-01T00:00:00Z", + "finished_at": null, + "user_id": null, + "user_session_id": null, + "client_id": "050M2GA1850M2GA1850M2GA185", + "scope": "urn:mas:admin", + "user_agent": null, + "last_active_at": null, + "last_active_ip": null + }, + "links": { + "self": "/api/admin/v1/oauth2-sessions/02081040G2081040G2081040G2" + } + }, + { + "type": "oauth2-session", + "id": "030C1G60R30C1G60R30C1G60R3", + "attributes": { + "created_at": "1970-01-01T00:00:00Z", + "finished_at": "1970-01-01T00:00:00Z", + "user_id": "040G2081040G2081040G208104", + "user_session_id": "050M2GA1850M2GA1850M2GA185", + "client_id": "060R30C1G60R30C1G60R30C1G6", + "scope": "urn:matrix:org.matrix.msc2967.client:api:*", + "user_agent": "Mozilla/5.0", + "last_active_at": "1970-01-01T00:00:00Z", + "last_active_ip": "127.0.0.1" + }, + "links": { + "self": "/api/admin/v1/oauth2-sessions/030C1G60R30C1G60R30C1G60R3" + } + } + ], + "links": { + "self": "/api/admin/v1/oauth2-sessions?page[first]=3", + "first": "/api/admin/v1/oauth2-sessions?page[first]=3", + "last": "/api/admin/v1/oauth2-sessions?page[last]=3", + "next": "/api/admin/v1/oauth2-sessions?page[after]=030C1G60R30C1G60R30C1G60R3&page[first]=3" + } + } + } + } + }, + "404": { + "description": "User was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User ID 00000000000000000000000000 not found" + } + ] + } + } + } + }, + "400": { + "description": "Invalid scope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Invalid scope \"not a valid scope\" in filter parameters" + } + ] + } + } + } + } + } + } + }, "/api/admin/v1/users": { "get": { "tags": [ @@ -743,29 +974,47 @@ "type": "string", "pattern": "^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$" }, - "UserFilter": { + "OAuth2SessionFilter": { "type": "object", "properties": { - "filter[can_request_admin]": { - "description": "Retrieve users with (or without) the `can_request_admin` flag set", - "type": "boolean", + "filter[user]": { + "description": "Retrieve the items for the given user", + "$ref": "#/components/schemas/ULID", "nullable": true }, + "filter[client]": { + "description": "Retrieve the items for the given client", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "filter[user-session]": { + "description": "Retrieve the items started from the given browser session", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "filter[scope]": { + "description": "Retrieve the items with the given scope", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, "filter[status]": { - "description": "Retrieve the items with the given status\n\nDefaults to retrieve all users, including locked ones.\n\n* `active`: Only retrieve active users\n\n* `locked`: Only retrieve locked users", - "$ref": "#/components/schemas/UserStatus", + "description": "Retrieve the items with the given status\n\nDefaults to retrieve all sessions, including finished ones.\n\n* `active`: Only retrieve active sessions\n\n* `finished`: Only retrieve finished sessions", + "$ref": "#/components/schemas/OAuth2SessionStatus", "nullable": true } } }, - "UserStatus": { + "OAuth2SessionStatus": { "type": "string", "enum": [ "active", - "locked" + "finished" ] }, - "PaginatedResponse_for_User": { + "PaginatedResponse_for_OAuth2Session": { "description": "A top-level response with a page of resources", "type": "object", "required": [ @@ -782,7 +1031,7 @@ "description": "The list of resources", "type": "array", "items": { - "$ref": "#/components/schemas/SingleResource_for_User" + "$ref": "#/components/schemas/SingleResource_for_OAuth2Session" } }, "links": { @@ -805,7 +1054,7 @@ } } }, - "SingleResource_for_User": { + "SingleResource_for_OAuth2Session": { "description": "A single resource, with its type, ID, attributes and related links", "type": "object", "required": [ @@ -825,7 +1074,7 @@ }, "attributes": { "description": "The attributes of the resource", - "$ref": "#/components/schemas/User" + "$ref": "#/components/schemas/OAuth2Session" }, "links": { "description": "Related links", @@ -833,33 +1082,60 @@ } } }, - "User": { - "description": "A user", + "OAuth2Session": { + "description": "A OAuth 2.0 session", "type": "object", "required": [ - "can_request_admin", + "client_id", "created_at", - "username" + "scope" ], "properties": { - "username": { - "description": "The username (localpart) of the user", - "type": "string" - }, "created_at": { - "description": "When the user was created", + "description": "When the object was created", "type": "string", "format": "date-time" }, - "locked_at": { - "description": "When the user was locked. If null, the user is not locked.", + "finished_at": { + "description": "When the session was finished", "type": "string", "format": "date-time", "nullable": true }, - "can_request_admin": { - "description": "Whether the user can request admin privileges.", - "type": "boolean" + "user_id": { + "description": "The ID of the user who owns the session", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "user_session_id": { + "description": "The ID of the browser session which started this session", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "client_id": { + "description": "The ID of the client which requested this session", + "$ref": "#/components/schemas/ULID" + }, + "scope": { + "description": "The scope granted for this session", + "type": "string" + }, + "user_agent": { + "description": "The user agent string of the client which started this session", + "type": "string", + "nullable": true + }, + "last_active_at": { + "description": "The last time the session was active", + "type": "string", + "format": "date-time", + "nullable": true + }, + "last_active_ip": { + "description": "The last IP address used by the session", + "type": "string", + "format": "ip", + "nullable": true } } }, @@ -938,6 +1214,112 @@ } } }, + "UserFilter": { + "type": "object", + "properties": { + "filter[can_request_admin]": { + "description": "Retrieve users with (or without) the `can_request_admin` flag set", + "type": "boolean", + "nullable": true + }, + "filter[status]": { + "description": "Retrieve the items with the given status\n\nDefaults to retrieve all users, including locked ones.\n\n* `active`: Only retrieve active users\n\n* `locked`: Only retrieve locked users", + "$ref": "#/components/schemas/UserStatus", + "nullable": true + } + } + }, + "UserStatus": { + "type": "string", + "enum": [ + "active", + "locked" + ] + }, + "PaginatedResponse_for_User": { + "description": "A top-level response with a page of resources", + "type": "object", + "required": [ + "data", + "links", + "meta" + ], + "properties": { + "meta": { + "description": "Response metadata", + "$ref": "#/components/schemas/PaginationMeta" + }, + "data": { + "description": "The list of resources", + "type": "array", + "items": { + "$ref": "#/components/schemas/SingleResource_for_User" + } + }, + "links": { + "description": "Related links", + "$ref": "#/components/schemas/PaginationLinks" + } + } + }, + "SingleResource_for_User": { + "description": "A single resource, with its type, ID, attributes and related links", + "type": "object", + "required": [ + "attributes", + "id", + "links", + "type" + ], + "properties": { + "type": { + "description": "The type of the resource", + "type": "string" + }, + "id": { + "description": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "attributes": { + "description": "The attributes of the resource", + "$ref": "#/components/schemas/User" + }, + "links": { + "description": "Related links", + "$ref": "#/components/schemas/SelfLinks" + } + } + }, + "User": { + "description": "A user", + "type": "object", + "required": [ + "can_request_admin", + "created_at", + "username" + ], + "properties": { + "username": { + "description": "The username (localpart) of the user", + "type": "string" + }, + "created_at": { + "description": "When the user was created", + "type": "string", + "format": "date-time" + }, + "locked_at": { + "description": "When the user was locked. If null, the user is not locked.", + "type": "string", + "format": "date-time", + "nullable": true + }, + "can_request_admin": { + "description": "Whether the user can request admin privileges.", + "type": "boolean" + } + } + }, "AddUserRequest": { "title": "JSON payload for the `POST /api/admin/v1/users` endpoint", "type": "object",