Skip to main content

mas_handlers/admin/
mod.rs

1// Copyright 2025, 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2024 The Matrix.org Foundation C.I.C.
4//
5// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6// Please see LICENSE files in the repository root for full details.
7
8use std::sync::Arc;
9
10use aide::{
11    axum::ApiRouter,
12    openapi::{OAuth2Flow, OAuth2Flows, OpenApi, SecurityScheme, Server, Tag},
13    transform::TransformOpenApi,
14};
15use axum::{
16    Json, Router,
17    extract::{FromRef, FromRequestParts, State},
18    http::HeaderName,
19    response::Html,
20};
21use hyper::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
22use indexmap::IndexMap;
23use mas_axum_utils::InternalError;
24use mas_data_model::{AppVersion, BoxRng, SiteConfig};
25use mas_http::CorsLayerExt;
26use mas_matrix::HomeserverConnection;
27use mas_policy::PolicyFactory;
28use mas_router::{
29    ApiDoc, ApiDocCallback, OAuth2AuthorizationEndpoint, OAuth2TokenEndpoint, Route, SimpleRoute,
30    UrlBuilder,
31};
32use mas_templates::{ApiDocContext, Templates};
33use schemars::transform::AddNullable;
34use tower_http::cors::{Any, CorsLayer};
35
36mod call_context;
37mod model;
38mod params;
39mod response;
40mod schema;
41mod v1;
42
43use self::call_context::CallContext;
44use crate::passwords::PasswordManager;
45
46fn finish(t: TransformOpenApi) -> TransformOpenApi {
47    t.title("Matrix Authentication Service admin API")
48        .tag(Tag {
49            name: "server".to_owned(),
50            description: Some("Information about the server".to_owned()),
51            ..Tag::default()
52        })
53        .tag(Tag {
54            name: "compat-session".to_owned(),
55            description: Some("Manage compatibility sessions from legacy clients".to_owned()),
56            ..Tag::default()
57        })
58        .tag(Tag {
59            name: "policy-data".to_owned(),
60            description: Some("Manage the dynamic policy data".to_owned()),
61            ..Tag::default()
62        })
63        .tag(Tag {
64            name: "oauth2-client".to_owned(),
65            description: Some("Manage OAuth 2.0 clients".to_owned()),
66            ..Tag::default()
67        })
68        .tag(Tag {
69            name: "oauth2-session".to_owned(),
70            description: Some("Manage OAuth2 sessions".to_owned()),
71            ..Tag::default()
72        })
73        .tag(Tag {
74            name: "user".to_owned(),
75            description: Some("Manage users".to_owned()),
76            ..Tag::default()
77        })
78        .tag(Tag {
79            name: "user-email".to_owned(),
80            description: Some("Manage emails associated with users".to_owned()),
81            ..Tag::default()
82        })
83        .tag(Tag {
84            name: "user-session".to_owned(),
85            description: Some("Manage browser sessions of users".to_owned()),
86            ..Tag::default()
87        })
88        .tag(Tag {
89            name: "user-registration-token".to_owned(),
90            description: Some("Manage user registration tokens".to_owned()),
91            ..Tag::default()
92        })
93        .tag(Tag {
94            name: "upstream-oauth-link".to_owned(),
95            description: Some(
96                "Manage links between local users and identities from upstream OAuth 2.0 providers"
97                    .to_owned(),
98            ),
99            ..Default::default()
100        })
101        .tag(Tag {
102            name: "upstream-oauth-provider".to_owned(),
103            description: Some("Manage upstream OAuth 2.0 providers".to_owned()),
104            ..Tag::default()
105        })
106        .security_scheme("oauth2", oauth_security_scheme(None))
107        .security_scheme(
108            "token",
109            SecurityScheme::Http {
110                scheme: "bearer".to_owned(),
111                bearer_format: None,
112                description: Some("An access token with access to the admin API".to_owned()),
113                extensions: IndexMap::default(),
114            },
115        )
116        .security_requirement_scopes("oauth2", ["urn:mas:admin"])
117        .security_requirement_scopes("bearer", ["urn:mas:admin"])
118}
119
120fn oauth_security_scheme(url_builder: Option<&UrlBuilder>) -> SecurityScheme {
121    let (authorization_url, token_url) = if let Some(url_builder) = url_builder {
122        (
123            url_builder.oauth_authorization_endpoint().to_string(),
124            url_builder.oauth_token_endpoint().to_string(),
125        )
126    } else {
127        // This is a dirty fix for Swagger UI: when it joins the URLs with the
128        // base URL, if the path starts with a slash, it will go to the root of
129        // the domain instead of the API root.
130        // It works if we make it explicitly relative
131        (
132            format!(".{}", OAuth2AuthorizationEndpoint::PATH),
133            format!(".{}", OAuth2TokenEndpoint::PATH),
134        )
135    };
136
137    let scopes = IndexMap::from([(
138        "urn:mas:admin".to_owned(),
139        "Grant access to the admin API".to_owned(),
140    )]);
141
142    SecurityScheme::OAuth2 {
143        flows: OAuth2Flows {
144            client_credentials: Some(OAuth2Flow::ClientCredentials {
145                refresh_url: Some(token_url.clone()),
146                token_url: token_url.clone(),
147                scopes: scopes.clone(),
148            }),
149            authorization_code: Some(OAuth2Flow::AuthorizationCode {
150                authorization_url,
151                refresh_url: Some(token_url.clone()),
152                token_url,
153                scopes,
154            }),
155            implicit: None,
156            password: None,
157        },
158        description: None,
159        extensions: IndexMap::default(),
160    }
161}
162
163pub fn router<S>() -> (OpenApi, Router<S>)
164where
165    S: Clone + Send + Sync + 'static,
166    Arc<dyn HomeserverConnection>: FromRef<S>,
167    PasswordManager: FromRef<S>,
168    BoxRng: FromRequestParts<S>,
169    CallContext: FromRequestParts<S>,
170    Templates: FromRef<S>,
171    UrlBuilder: FromRef<S>,
172    Arc<PolicyFactory>: FromRef<S>,
173    SiteConfig: FromRef<S>,
174    AppVersion: FromRef<S>,
175{
176    // We *always* want to explicitly set the possible responses, beacuse the
177    // infered ones are not necessarily correct
178    aide::generate::infer_responses(false);
179
180    aide::generate::in_context(|ctx| {
181        ctx.schema = schemars::generate::SchemaGenerator::new(
182            schemars::generate::SchemaSettings::openapi3().with(|settings| {
183                // Remove the transform which adds nullable fields, as it's not
184                // valid with OpenAPI 3.1. For some reason, aide/schemars output
185                // an OpenAPI 3.1 schema with this nullable transform.
186                settings
187                    .transforms
188                    .retain(|transform| !transform.is::<AddNullable>());
189            }),
190        );
191    });
192
193    let mut api = OpenApi::default();
194    let router = ApiRouter::<S>::new()
195        .nest("/api/admin/v1", self::v1::router())
196        .finish_api_with(&mut api, finish);
197
198    let router = router
199        // Serve the OpenAPI spec as JSON
200        .route(
201            "/api/spec.json",
202            axum::routing::get({
203                let api = api.clone();
204                move |State(url_builder): State<UrlBuilder>| {
205                    // Let's set the servers to the HTTP base URL
206                    let mut api = api.clone();
207
208                    let _ = TransformOpenApi::new(&mut api)
209                        .server(Server {
210                            url: url_builder.http_base().to_string(),
211                            ..Server::default()
212                        })
213                        .security_scheme("oauth2", oauth_security_scheme(Some(&url_builder)));
214
215                    std::future::ready(Json(api))
216                }
217            }),
218        )
219        // Serve the Swagger API reference
220        .route(ApiDoc::route(), axum::routing::get(swagger))
221        .route(
222            ApiDocCallback::route(),
223            axum::routing::get(swagger_callback),
224        )
225        .layer(
226            CorsLayer::new()
227                .allow_origin(Any)
228                .allow_methods(Any)
229                .allow_otel_headers([
230                    AUTHORIZATION,
231                    ACCEPT,
232                    CONTENT_TYPE,
233                    // Swagger will send this header, so we have to allow it to avoid CORS errors
234                    HeaderName::from_static("x-requested-with"),
235                ]),
236        );
237
238    (api, router)
239}
240
241async fn swagger(
242    State(url_builder): State<UrlBuilder>,
243    State(templates): State<Templates>,
244) -> Result<Html<String>, InternalError> {
245    let ctx = ApiDocContext::from_url_builder(&url_builder);
246    let res = templates.render_swagger(&ctx)?;
247    Ok(Html(res))
248}
249
250async fn swagger_callback(
251    State(url_builder): State<UrlBuilder>,
252    State(templates): State<Templates>,
253) -> Result<Html<String>, InternalError> {
254    let ctx = ApiDocContext::from_url_builder(&url_builder);
255    let res = templates.render_swagger_callback(&ctx)?;
256    Ok(Html(res))
257}