mas_handlers/admin/
mod.rs

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